From af2cfebc7c7e566061691ec279b06298faee0d1c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:15:38 +0700 Subject: [PATCH 001/178] docs(linux): add the driver layer to the architecture guide --- linux/ARCHITECTURE.md | 164 ++++++++++++++++++++++++++++++------------ 1 file changed, 120 insertions(+), 44 deletions(-) diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 5c7bd5797d..b83f669062 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -13,59 +13,58 @@ linux/ ├── po/ translation catalogue └── crates/ ├── app/ binary, GTK4 entry point, Relm4 components - ├── core/ domain types and traits, no GUI deps - ├── net/ TCP dialer and rustls client configs + ├── core/ domain types and the driver contract, no GUI deps + ├── net/ direct TCP transport and rustls client configs ├── ssh/ system OpenSSH transport and the askpass helper + ├── session/ connect, sessions, health monitor, operation runner ├── storage/ secrets, settings, file persistence ├── test-fixtures/ dev-only container fixtures with a generated PKI + ├── test-support/ dev-only fakes and the conformance suites └── drivers/ - ├── postgres/ sqlx-postgres impl - ├── mysql/ sqlx-mysql impl - ├── sqlite/ sqlx-sqlite impl - └── ... one crate per database engine + ├── postgres/ tokio-postgres + ├── mysql/ sqlx-mysql, MySQL and MariaDB + ├── sqlite/ sqlx-sqlite + ├── mssql/ tiberius + └── clickhouse/ clickhouse over hyper ``` ## Dependency graph -``` - ┌─────────┐ - │ app │ binary, all GUI code - └────┬────┘ - ┌─────────┼──────────────────┐ - ▼ ▼ ▼ - ┌────────┐ ┌─────────┐ ┌──────────────────┐ - │ core │ │ storage │ │ drivers/* │ - └────────┘ └────┬────┘ └─────────┬────────┘ - │ │ - └────► ┌────────┐ ◄ - │ core │ - └────────┘ -``` +| Crate | Depends on | +|---|---| +| `core` | no workspace crate | +| `net`, `ssh`, `storage` | `core` | +| `drivers/postgres`, `drivers/mysql`, `drivers/mssql`, `drivers/clickhouse` | `core`, `net` | +| `drivers/sqlite` | `core` | +| `session` | `core`, `net`, `ssh`, `storage` | +| `app` | every crate above | Rules, enforced by review: -- `core` depends on **no other workspace crate**. Only the standard library and small utility crates (`async-trait`, `serde`, `thiserror`). -- `storage` depends on `core` only. -- Each `drivers/` crate depends on `core` only. **Drivers never depend on each other.** -- `app` depends on `core`, `storage`, and every `drivers/*`. It is the only crate that knows about every driver. -- No reverse dependencies. `core` never imports anything from `drivers/*` or `app`. +- `core` depends on **no other workspace crate**. It holds the domain types and the driver contract, and no I/O beyond the traits. +- `net`, `ssh` and `storage` depend on `core` only. +- Each `drivers/` crate depends on `core`, and on `net` when it speaks TLS or dials TCP. **Drivers never depend on each other, on `session` or on `ssh`.** +- `session` depends on `core`, `net`, `ssh` and `storage`, and on no driver: it reaches drivers through `DriverRegistry`. +- `app` depends on all of them. It is the only crate that names every driver. +- `test-support` and `test-fixtures` are dev-dependencies only. Consequences: -- Adding a driver does not touch `core`, `storage`, or any other driver. +- Adding a driver does not touch `core`, `session`, `storage` or any other driver. - Replacing the GUI framework would require rewriting only `app`. -- Drivers can be unit-tested against `core` traits without pulling GTK. -- The build graph is shallow — incremental rebuilds stay fast. +- Drivers are tested against `core` traits without pulling GTK. ## Composition root -The driver registry is built once in `app::main` before the GTK application starts running: +The driver registry is built once, by `build_registry` in `crates/app/src/lib.rs`, before the GTK application starts running: ```rust fn build_registry() -> DriverRegistry { let mut r = DriverRegistry::new(); - r.register(Arc::new(drivers_postgres::PgDriver)); + r.register(Arc::new(drivers_clickhouse::ClickhouseDriver)); + r.register(Arc::new(drivers_mssql::MssqlDriver)); r.register(Arc::new(drivers_mysql::MysqlDriver)); + r.register(Arc::new(drivers_postgres::PgDriver)); r.register(Arc::new(drivers_sqlite::SqliteDriver)); r } @@ -99,23 +98,100 @@ The `app` crate uses [Relm4](https://relm4.org) for component-based UI structure See [docs/state-management.md](docs/state-management.md) for the patterns and naming we use. -## Driver contract +## Driver layer -Every driver crate exports a single zero-sized struct that implements `core::DatabaseDriver`. The trait is async (via `async_trait`), small, and stable. +How the app reaches a database: the contract in `tablepro-core`, a transport that carries the bytes, TLS each driver builds from the connection's trust settings, and a session that watches the link. -```rust -#[async_trait::async_trait] -pub trait DatabaseDriver: Send + Sync { - fn id(&self) -> &'static str; - fn display_name(&self) -> &'static str; - fn default_port(&self) -> u16; - async fn connect(&self, opts: ConnectOptions) -> Result, DriverError>; -} -``` +### The contract + +`DatabaseDriver` describes an engine before any connection exists, through `DriverCapabilities`, and opens a `Connection`. The `Connection` serves the workspace from a pool; every call takes `CallOptions` (a deadline and a cancel token) and runs on a task the connection owns (`OwnedCalls`), so a stop reaches the server instead of leaving the statement running. Saves report what they left in the database through a `WriteLedger`. Each editor tab gets an `EditorSession` of its own that streams results into a `ResultSink`. [docs/adding-drivers.md](docs/adding-drivers.md) is the full contract, [ADR 0016](docs/decisions/0016-driver-contract-v2.md) the reasons for it, and the engine pages say how each driver meets it: [PostgreSQL](docs/drivers/postgresql.md), [MySQL and MariaDB](docs/drivers/mysql.md), [SQLite](docs/drivers/sqlite.md), [SQL Server](docs/drivers/sql-server.md), [ClickHouse](docs/drivers/clickhouse.md). + +### Connecting + +`DriverConnector` in `tablepro-session` turns a saved connection into a connected one: + +1. It checks the request against the driver's capabilities: the auth mode, the TLS settings for the route, a required database name. +2. It builds the transport: `SshSession::connect` and then `SshSession::forward` for an SSH connection, or a `DirectTcpTransport`. +3. It calls `DatabaseDriver::connect` with `ConnectOptions`: the `NetworkTarget` (transport, credentials, database, TLS settings) or the `FileEndpoint`, the `WorkspaceSettings`, the `LivenessPolicy` and the app's version. +4. It reads the first catalogue, the schemas and the default schema's objects, under the metadata deadline. + +The cancel token is checked between stages, and a stage that finishes after a cancel closes what it built, because a driver blocked in a login cannot always be interrupted. + +### Transports + +`Transport` (`crates/core/src/transport.rs`) is how a driver reaches a server without knowing whether the route is direct or through SSH: + +- `service_endpoint()`: the host and port the user typed, which TLS verifies and Kerberos names. +- `kind()` and `route()`: `DirectTcp` with a TCP endpoint, or `SshForward` with the Unix socket the forward listens on. +- `open()`: a new byte stream to the server. +- `reroute(endpoint)`: the same kind of route to another endpoint, for SQL Server's login routing. +- `explain_closed_stream()`: why a stream closed, when the transport knows better than "connection lost", as when the SSH master exited. + +| | `DirectTcpTransport` (`tablepro-net`) | `SshForwardTransport` (`tablepro-ssh`) | +|---|---|---| +| `open` | Resolves the name, then tries each address in turn under the connect timeout | Connects to the forward's Unix socket under the connect timeout | +| Socket options | `TCP_NODELAY`; keepalive after 30 s idle, every 10 s, 3 probes; `TCP_USER_TIMEOUT` 45 s | OpenSSH's own: `ServerAliveInterval=15`, `ServerAliveCountMax=3` | +| `reroute` | A new direct transport | A new forward on the same master | +| `explain_closed_stream` | Nothing to add | The master's exit, or the "open failed" line ssh printed | + +PostgreSQL, SQL Server and ClickHouse open every stream through `open()`. sqlx dials its own sockets, so the MySQL driver passes it the route instead: host and port, or the forward's socket path. Nothing relays bytes between the two. The socket options above apply only to streams `open()` makes, so a direct MySQL connection has no keepalive and finds a dead peer through the probe and its deadlines. + +SSH runs the system OpenSSH client ([ADR 0013](docs/decisions/0013-openssh-client-transport.md)): one ControlMaster (`ssh -M -N -T`) per connection, with the user's `~/.ssh/config` applied, and one `ssh -O forward` Unix socket per server it reaches. The sockets live under `$XDG_RUNTIME_DIR/app//ssh/` in directories only the user can open, with mode 0600. Every prompt (host key, password, passphrase, keyboard-interactive) reaches the app through the `tablepro-askpass` helper, and the 20 s handshake timeout pauses while a prompt is open. Under Flatpak, `ssh` comes from the GNOME runtime, and a `ProxyCommand` or `Match exec` that runs a host program, the host's `/etc/ssh` and FIDO keys kept in files do not work. + +### Trust + +A connection's `TlsConfig` holds the `TlsMode`, an optional CA file, an optional client certificate and key, and an optional server name: + +- `Disable`: no TLS. +- `Require`: encrypted, and the certificate is not checked. +- `VerifyFull`: the certificate must chain to a trusted root and carry the server name. + +The name checked is `NetworkTarget::tls_server_name()`: the override when one is set, otherwise the host the user typed, never the address of a local SSH forward. `TlsConfig::validate` refuses a mode, file or override the driver cannot use on that route, and the connect dialog reads the chosen files up front, so a file with no certificate is named before anything connects. + +Each engine's TLS stack takes trust in its own form, so the driver builds it: + +| Engine | `VerifyFull` trusts | Client certificate | A failure is classified from | +|---|---|---|---| +| PostgreSQL | The system store (rustls-platform-verifier), plus the CA file | Yes | The rustls error | +| ClickHouse | The system store (rustls-platform-verifier), plus the CA file | Yes, as the login, with no password sent | The rustls error | +| MySQL, MariaDB | The Mozilla roots compiled into sqlx, plus the CA file. Not the system store | Yes | The rustls error | +| SQL Server | The system store (loaded by tiberius), plus the CA file | No | rustls's message text, because tiberius keeps nothing else | +| SQLite | No TLS | | | + +PostgreSQL and ClickHouse get their rustls `ClientConfig` from `tablepro_net::tls::build_client_config`. Every engine uses the aws-lc-rs provider ([ADR 0009](docs/decisions/0009-crypto-provider-and-sources.md)). A CA file is added to the engine's roots, never in place of them. `docs/error-handling.md` lists [what each TLS failure means and what the app offers](docs/error-handling.md#tlsfailure-and-its-remedies). + +On Linux the system store is the distribution's CA bundle. Under Flatpak it is the runtime's bundle: a CA added on the host with `update-ca-certificates` or `update-ca-trust` is not visible in the sandbox (freedesktop-sdk issue 1905). + +#### Choosing a CA file under Flatpak + +- For a server whose certificate comes from a private CA, choose the CA's PEM file in the connection's TLS settings, whatever the host trusts. +- The development manifest grants `--filesystem=home`, so a file under your home folder is read at its real path. +- A file chosen from outside the home folder comes back from the file chooser portal as a path under `/run/user//doc/`. It works for as long as the portal keeps the grant. If it goes, the connect fails naming the CA file, and choosing the file again fixes it. Keeping a copy of the CA in your home folder avoids this. +- The file is read at every connect, so a replaced CA applies on the next connect. + +### Liveness and deadlines + +`LivenessPolicy::DESKTOP` bounds every phase of a connection: + +| Bound | Value | +|---|---| +| TCP connect, per address | 10 s | +| Login, including TLS | 15 s | +| TCP keepalive | after 30 s idle, every 10 s, 3 probes | +| `TCP_USER_TIMEOUT` | 45 s | +| Probe | 5 s | +| Wait for a pool slot | 5 s | +| Cancel grace, after a stop | 5 s | +| SSH handshake, paused while a prompt is open | 20 s | +| SSH forward request | 10 s | +| SSH keepalive | every 15 s, 3 missed | +| Close | 10 s | + +`DefaultDeadlines` in `tablepro-session` sets each operation's deadline: metadata 15 s, a page or a count 60 s, row changes 120 s, schema changes none. A call returns within its deadline plus the cancel grace. -A `Connection` exposes the operations that `app` needs: list tables, fetch rows, run a query, etc. The full surface is defined in `core::connection`. +### Sessions and health -The full step-by-step guide for adding a driver lives in [docs/adding-drivers.md](docs/adding-drivers.md). +A window holds one `Session`. Every use of its connection goes through the `OperationRunner`, which adds no timer of its own: the deadline travels in `CallOptions` so the driver stops the statement on the server. The session's monitor probes every 30 s and calls the connection lost after three failed probes in a row, or as soon as `Connection::lost()` fires, which a driver resolves only when the link is gone, never for one dead backend in its pool. It then reconnects on a backoff from 5 s to 60 s. A failure that a retry cannot fix (authentication, TLS, configuration, a host key to review) parks the session until the user acts. A read that failed because its connection was lost runs once more on the replacement link; a save never does. ## Workspace tab system @@ -174,7 +250,7 @@ Meson is the build system and Cargo is the Rust compiler; see [ADR 0006](docs/de The floor is GNOME 50 (GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88); see [ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md). The host runner image (`ubuntu-24.04`) ships GLib 2.80, well below that, so the system-deps check fails there. -`.github/workflows/build-linux.yml` runs the **Fast checks** job inside a `container: ubuntu:26.04`, which ships GLib 2.88 and the meson, appstream and desktop-file-utils packages the data suite needs. The container is minimal, so the install step pulls `ca-certificates`, `curl` and `git` before rust-toolchain and Swatinem can run. The **integration** job stays on the host runner because the driver crates depend only on `tablepro-core` and don't pull libadwaita. +`.github/workflows/build-linux.yml` runs the **Fast checks** job inside a `container: ubuntu:26.04`, which ships GLib 2.88 and the meson, appstream and desktop-file-utils packages the data suite needs. The container is minimal, so the install step pulls `ca-certificates`, `curl` and `git` before rust-toolchain and Swatinem can run. The **integration** job stays on the host runner because the driver crates depend only on `tablepro-core` and `tablepro-net` and don't pull libadwaita. ## Out of scope From fe32cfd6b8ddd4967978595574aff6a278d255b0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:15:40 +0700 Subject: [PATCH 002/178] docs(linux): rewrite the driver guide for contract v2 --- linux/docs/adding-drivers.md | 468 +++++++++++++++++++++++------------ 1 file changed, 303 insertions(+), 165 deletions(-) diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index 6bb46903aa..4424fae499 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -1,239 +1,377 @@ # Adding a database driver -This is the canonical contributor task. Every database engine TablePro Linux supports has a driver crate under `crates/drivers/`. There is no plugin system: a driver is a Rust crate, statically linked, registered in one place at startup. See [decisions/0001-no-plugin-system.md](decisions/0001-no-plugin-system.md) for why. +Every engine is one crate under `crates/drivers/`, linked into the app at compile time and registered in one place. There is no plugin system ([ADR 0001](decisions/0001-no-plugin-system.md)). The contract a driver implements lives in `tablepro-core` and is described here; [ADR 0016](decisions/0016-driver-contract-v2.md) records why it has this shape. How each shipped engine meets it: [PostgreSQL](drivers/postgresql.md), [MySQL and MariaDB](drivers/mysql.md), [SQLite](drivers/sqlite.md), [SQL Server](drivers/sql-server.md), [ClickHouse](drivers/clickhouse.md). -End to end, adding a driver is six steps: +The work, in order: -1. Pick the underlying Rust library -2. Create a new crate -3. Implement `core::DatabaseDriver` and `core::Connection` -4. Add the crate to the workspace -5. Register the driver in `app::main` -6. Add tests +1. Choose the client library. +2. Create the crate. +3. Implement `DatabaseDriver` and its capabilities. +4. Implement `Connection` on `OwnedCalls`. +5. Write saves through a `WriteLedger`. +6. Implement `EditorSession`. +7. Implement `SqlDialect`, and `DdlDialect` where the app edits the engine's structure. +8. Register the driver. +9. Add a container fixture, the conformance suites and the engine's own tests. +10. Write the engine's page in `docs/drivers/`. -Each step is small. The whole task takes between half a day (PG-shaped engines) and a week (Oracle-shaped engines that need C FFI). +## 1. The client library -## 1. Pick the Rust library +The library must: -| Engine | Recommended crate | Notes | -|---|---|---| -| PostgreSQL | `sqlx` with `runtime-tokio` + `tls-rustls` + `postgres` | Fully async, prepared statements, streaming. | -| MySQL / MariaDB | `sqlx` with `mysql` feature | Same shape as PostgreSQL. | -| SQLite | `sqlx` with `sqlite` feature | File-based, no network. | -| MSSQL | `tiberius` | Pure Rust TDS. Watch governance — `praxiomlabs/rust-mssql-driver` is a credible alternative. | -| Oracle | `oracle` (rust-oracle, kubo) | Wraps ODPI-C. Requires Oracle Instant Client on the build host. | -| ClickHouse | official `clickhouse` crate | HTTP interface (8123). Dynamic results streamed via `FORMAT JSONCompactEachRowWithNamesAndTypes`. | -| Redis | `fred` | Modern tokio rewrite of redis-rs. | -| MongoDB | official `mongodb` | Mature, OpenTelemetry support. | -| DuckDB | `duckdb` (official) | Bundled native lib, edition 2024. | -| Cassandra / Scylla | `scylla` | Cassandra-compatible, shard-aware. | -| DynamoDB | `aws-sdk-dynamodb` | Type-safe AWS SDK. | -| BigQuery | `google-cloud-bigquery` (third-party) | No first-party Google SDK. | - -If the engine is not listed, open an issue first and discuss the crate choice before writing code. +- be maintained and published on crates.io, used without a fork ([ADR 0005](decisions/0005-supply-chain-policy.md)); `deny.toml` allows no git source; +- use rustls with aws-lc-rs, or no TLS of its own ([ADR 0009](decisions/0009-crypto-provider-and-sources.md)); +- either take a stream the driver hands it, or dial a TCP endpoint and a Unix socket path itself, because an SSH forward is a Unix socket ([Transports](../ARCHITECTURE.md#transports)); +- have a way to stop a statement already sent: a cancel request, a KILL, an attention, an interrupt; +- let the driver keep a connection clean for the next caller, with no server-side state it did not ask for. -## 2. Create the crate +What the shipped drivers use: -Convention: `crates/drivers//`. +| Engine | Client | Pool | Reaches the server through | +|---|---|---|---| +| PostgreSQL | tokio-postgres 0.7.18, tokio-postgres-rustls 0.14 | deadpool-postgres 0.14.2 | `Transport::open` | +| MySQL, MariaDB | sqlx 0.9 (`mysql`) | deadpool 0.13 | `Transport::route`, dialled by sqlx | +| SQLite | sqlx 0.9 (`sqlite-unbundled`) | sqlx, one connection | a file path | +| SQL Server | tiberius 0.13 | deadpool 0.13 | `Transport::open` | +| ClickHouse | clickhouse 0.15 over hyper-util and hyper-rustls | hyper's connection pool | `Transport::open` | -```bash -cd crates/drivers -cargo new --lib clickhouse -cd clickhouse -``` +Open an issue before starting an engine that is not listed, and say which library and why. -The crate is named `tablepro-driver-` in `Cargo.toml`. The library crate name is `drivers_` (Rust-conventional underscore). +## 2. The crate -Skeleton `Cargo.toml`: +`crates/drivers//`, package `tablepro-driver-`, library `drivers_`: ```toml [package] -name = "tablepro-driver-clickhouse" -version = "0.1.0" -edition = "2024" +name = "tablepro-driver-" +edition.workspace = true +rust-version.workspace = true +license.workspace = true publish = false [lib] -name = "drivers_clickhouse" +name = "drivers_" path = "src/lib.rs" [dependencies] tablepro-core = { path = "../../core" } -async-trait = "0.1" -clickhouse = { version = "0.15", default-features = false, features = ["rustls-tls"] } -tokio = { version = "1", features = ["rt", "macros", "net", "time"] } -thiserror = "2" +tablepro-net = { path = "../../net" } +async-trait.workspace = true +tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } +tokio-util = { workspace = true, features = ["rt"] } +tracing.workspace = true + +[dev-dependencies] +tablepro-test-fixtures = { path = "../../test-fixtures" } +tablepro-test-support = { path = "../../test-support" } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } + +[lints] +workspace = true ``` -Do not add unrelated dependencies. Do not depend on `gtk4`, `libadwaita`, or any other workspace crate except `tablepro-core`. +A driver depends on `tablepro-core`, and on `tablepro-net` when it speaks TLS or dials TCP. It never depends on GTK, on `tablepro-session`, on `tablepro-ssh` or on another driver. Add the crate to `members` in `linux/Cargo.toml`. -## 3. Implement the traits - -Two traits, both defined in `tablepro-core`: +## 3. `DatabaseDriver` and capabilities ```rust -#[async_trait::async_trait] +#[async_trait] pub trait DatabaseDriver: Send + Sync { fn id(&self) -> &'static str; fn display_name(&self) -> &'static str; - fn default_port(&self) -> u16; - async fn connect(&self, opts: ConnectOptions) -> Result, DriverError>; + fn capabilities(&self) -> &'static DriverCapabilities; + async fn connect( + &self, + options: ConnectOptions, + cancel: CancellationToken, + ) -> Result, DriverError>; } +``` -#[async_trait::async_trait] -pub trait Connection: Send + Sync { - async fn list_tables(&self) -> Result, DriverError>; - async fn fetch_columns(&self, table: &str) -> Result, DriverError>; - async fn fetch_rows(&self, table: &str, offset: u64, limit: u64) -> Result; - async fn execute(&self, sql: &str) -> Result; - async fn ping(&self) -> Result<(), DriverError>; - async fn close(self: Box) -> Result<(), DriverError>; -} +`id()` is written into saved connections and never changes once shipped. The ids in use are `postgres`, `mysql`, `sqlite`, `mssql` and `clickhouse`. + +### Capabilities + +`DriverCapabilities` is a `static` the driver returns before any connection exists. The connect dialog and `DriverConnector` read `endpoint`, `auth_modes`, `tls`, `read_only` and `database_required`. The conformance suites hold `endpoint`, `dml_writes` and the TLS client certificate flag to what the engine does. + +| Field | Meaning | +|---|---| +| `endpoint` | `Network { default_port }` or `File`. A file engine gets a path row instead of host, port, TLS and SSH. | +| `auth_modes` | The `AuthMode`s the driver can log in with. `Kerberos` only where `connect` maps it to a real integrated login. | +| `tls` | `TlsCapabilities`: the `TlsMode`s offered, whether a CA file and a client certificate are accepted, and where a server name override works (`ServerNameOverride`). `None` for an engine with no TLS. | +| `read_only` | How a read-only connection is held: `EngineEnforced` (the engine refuses every write), `SessionDefault` (a statement can lift it) or `LoginPrivileges` (only the login's grants hold). The connect dialog says which one the user gets. | +| `dml_writes`, `ddl_writes` | `WriteAtomicity` of row changes and of schema changes: `Transactional` or `StatementByStatement`. | +| `script_execution` | `NativeBatch` (the engine runs a script) or `SingleStatement` (one statement per run). | +| `commit_resolution` | How an unanswered commit can be resolved: `ServerTransactionStatus`, `QueryLog` or `Unavailable`. | +| `row_count_estimate` | `Statistics` ("about N"), `ExactMetadata` or `Unavailable`. | +| `object_kinds` | The `ObjectKind`s the sidebar can list. | +| `database_required` | Whether a connect without a database name is refused before anything is opened. | + +The shipped drivers: + +| | PostgreSQL | MySQL | SQLite | SQL Server | ClickHouse | +|---|---|---|---|---|---| +| `endpoint` | 5432 | 3306 | `File` | 1433 | 8123 | +| `auth_modes` | Password | Password | none | Password, Kerberos | Password | +| TLS modes | all three | all three | none | all three | all three | +| CA file | yes | yes | | yes | yes | +| Client certificate | yes | yes | | no | yes | +| Server name override | `AnyRoute` | `SshForwardOnly` | | `AnyRoute` | `AnyRoute` | +| `read_only` | `SessionDefault` | `SessionDefault` | `EngineEnforced` | `LoginPrivileges` | `EngineEnforced` | +| `dml_writes` | `Transactional` | `Transactional` | `Transactional` | `Transactional` | `StatementByStatement` | +| `ddl_writes` | `Transactional` | `StatementByStatement` | `Transactional` | `Transactional` | `StatementByStatement` | +| `script_execution` | `NativeBatch` | `NativeBatch` | `NativeBatch` | `NativeBatch` | `SingleStatement` | +| `commit_resolution` | `ServerTransactionStatus` | `Unavailable` | `Unavailable` | `Unavailable` | `QueryLog` | +| `row_count_estimate` | `Statistics` | `Statistics` | `Statistics` | `Statistics` | `ExactMetadata` | +| `database_required` | no | yes | no | no | no | + +SQL Server offers Kerberos only when built with the `kerberos` feature. MySQL's `dml_writes` is what InnoDB gives; the driver reads each table's storage engine before a save and writes statement by statement on one that cannot roll back. + +### Connecting + +`ConnectOptions` has no default, so a connection opened with a made-up host or timeout does not compile: + +- `target`: `ConnectTarget::Network(NetworkTarget)` or `ConnectTarget::File(FileEndpoint)`. A driver refuses the kind it does not serve with `DriverError::Unsupported`. +- `workspace_settings`: `WorkspaceSettings` with `read_only`, `statement_timeout` (60 s) and `lock_timeout` (10 s). +- `liveness`: `LivenessPolicy::DESKTOP` in the app; the conformance suite uses shorter values. +- `environment`: `ConnectEnvironment` with the app's version, sent next to `APPLICATION_NAME` where the protocol has room. + +A `NetworkTarget` carries `transport`, `credentials`, `database` and `tls`. `connect`: + +1. validates `tls` with `TlsConfig::validate(caps, transport.kind().class())` and refuses `Credentials` whose mode the driver does not support; +2. builds its TLS from `tls` (see [Trust](../ARCHITECTURE.md#trust)) and checks the certificate against `NetworkTarget::tls_server_name()`, never against the socket's address; +3. opens every stream with `transport.open()`, or dials `transport.route()` when the library dials itself; +4. logs in under `liveness.login_timeout`, with `APPLICATION_NAME` as the client name; +5. opens the first pool member before returning, so a refused password or a failed handshake is the connect's error, and gives up as soon as `cancel` fires; +6. reads `ServerInfo` and builds the dialect for that server version. + +A `ConnectionLost` during login goes through `Transport::explain_closed_stream`, which turns "the stream closed" into "the SSH master exited" where that is what happened. + +## 4. `Connection` + +The `Connection` is a workspace's link to one server: the pool behind the sidebar, the grid and every save. Every call takes `CallOptions`, a deadline and a `CancellationToken` that travel together: a deadline with no cancel leaves the server working after the app gave up, and a cancel with no deadline hangs on a server that never answers. + +| Method | Contract | +|---|---| +| `server_info`, `dialect`, `read_only` | Fixed at connect. | +| `link_state`, `lost` | A `LinkWatch` shared by every call. `lost()` resolves when the link is gone: a failed probe, or a failed call that a check outside the pool confirmed. One dead backend in the pool does not resolve it, so the session monitor can act on it. | +| `probe` | Whether the server answers. It never waits for a pool slot: with every slot busy it opens a connection of its own. | +| `list_schemas`, `list_objects`, `fetch_columns`, `fetch_indexes`, `fetch_foreign_keys`, `fetch_table_definition`, `row_identity`, `table_estimate` | Catalogue reads. `fetch_table_definition` reads columns, keys and dependents in one call so they come from the same moment. | +| `query_bound` | A generated read that keeps at most `limit` rows and reads one more to set `truncated`. | +| `count_rows` | A generated count. | +| `stream_read` | Every row of a read into a `ResultSink`, with no row limit, on a connection of its own, held read-only for its whole length. | +| `write_batch` | A save, refused before anything is sent on a read-only connection or when it asks to suspend foreign keys on an engine that cannot. | +| `resolve_commit` | What became of a commit whose answer never arrived. | +| `open_editor_session` | A tab's own connection, which outlives this one. | +| `close` | Stops every call and releases the pool. Idempotent, and never closes an editor session. | + +### `OwnedCalls` + +Dropping a future does not stop a server. Each call runs on a task the connection owns through `OwnedCalls::run_read` or `run_write`: + +```rust +self.calls + .run_read(options, PgCall::new(), stop.clone(), move |mut call, request| async move { + if let Err(error) = call.acquire(&pool, &timeouts, &request, &work_stop).await { + return (call, Err(error)); + } + let result = flight(&call, &begin, op).await; + (call, result) + }) + .await ``` -A driver crate exports two types: +- The work gets the `CallResource` and a `StopRequest`, and hands the resource back with its result. +- The supervisor races the work against the caller's cancel, the deadline and `close`. The first one to fire sets the `StopReason` (`Cancelled`, `DeadlineElapsed`, `Shutdown`) on the request. +- Dropping the caller's future is a cancel: the call keeps running on its task until the stop has settled. +- After a stop the work has `LivenessPolicy::cancel_grace` (5 s) to finish. Past it the call is abandoned: the resource's abandon flag is set, a read returns `ConnectionLost`, and a write returns what its `WriteLedger` saw. +- An error the engine raised to acknowledge the stop reads as what asked for it: `Cancelled`, or `Timeout { phase: Statement, server_cancelled: true }`. -- A zero-sized `*Driver` struct that implements `DatabaseDriver`. -- A connection struct (typically wrapping a connection pool from the underlying crate) that implements `Connection`. +Start the resource empty and fill it inside the work, as `PgCall::acquire` and `MysqlCall::acquire` do, so the deadline and the cancel cover the wait for a pool slot. Arm the engine stop once a connection is held, then check `StopRequest::is_requested` again: a stop asked for in between sent nothing. -Skeleton `src/lib.rs`: +### `EngineStop` and `StopDelivery` -```rust -use async_trait::async_trait; -use tablepro_core::{ - Connection, ConnectOptions, DatabaseDriver, DriverError, - ColumnInfo, ExecResult, QueryResult, TableInfo, -}; +`EngineStop` is the engine's own way of stopping work already sent: -pub struct ClickhouseDriver; +- `delivery()` says how the stop reaches the server. `OutOfBand(StopScope::Statement)` names the statement, so it cannot land on anything else. `OutOfBand(StopScope::Connection)` names the connection, so a late one can hit whatever that connection runs next. `InBand` means the running request carries it and the work sends it itself. +- `stop()` sends an out-of-band stop. `OwnedCalls` calls it at most once and polls it ahead of the work, since work waiting on the server cannot finish until the server has been told. +- `acknowledged(error)` says whether an error is the engine confirming the stop. -#[async_trait] -impl DatabaseDriver for ClickhouseDriver { - fn id(&self) -> &'static str { "clickhouse" } - fn display_name(&self) -> &'static str { "ClickHouse" } - fn default_port(&self) -> u16 { 8123 } - - async fn connect(&self, opts: ConnectOptions) -> Result, DriverError> { - let client = build_client(opts).await?; - Ok(Box::new(ClickhouseConnection { client })) - } -} +| Engine | Stop | Delivery | +|---|---|---| +| PostgreSQL | Cancel request over a new stream from the transport (`CancelToken::cancel_query_raw`) | `OutOfBand(Connection)` | +| MySQL | `KILL QUERY ` on a control connection over the same route | `OutOfBand(Connection)` | +| SQLite | The call's flag, read by a progress handler every 1,000 VM instructions | `OutOfBand(Statement)` | +| SQL Server | TDS attention on the request's own connection (`Client::cancel_query`) | `InBand` | +| ClickHouse | `KILL QUERY WHERE query_id = ? ASYNC` on a sessionless client | `OutOfBand(Statement)` | -struct ClickhouseConnection { - client: clickhouse::Client, -} +### `CallResource` release rules -#[async_trait] -impl Connection for ClickhouseConnection { - async fn list_tables(&self) -> Result, DriverError> { /* ... */ } - async fn fetch_columns(&self, table: &str) -> Result, DriverError> { /* ... */ } - async fn fetch_rows(&self, table: &str, offset: u64, limit: u64) -> Result { /* ... */ } - async fn execute(&self, sql: &str) -> Result { /* ... */ } - async fn ping(&self) -> Result<(), DriverError> { /* ... */ } - async fn close(self: Box) -> Result<(), DriverError> { /* ... */ } -} -``` +`CallResource` is the connection one call holds: `abandon_flag()` is what the pool's recycle and the resource's `Drop` read, and `discard()` takes the connection out of its pool and closes it. After the work returns: -Notes: +| Stop delivery | The call was stopped | Released to the pool when | Otherwise | +|---|---|---|---| +| `OutOfBand(Connection)` | yes | the engine acknowledged the stop and the stop itself settled | discarded | +| any | no, or not connection-scoped | the result is not `ConnectionLost` | discarded | +| any | abandoned past the grace | never | the abandon flag is set and the resource drops out of the pool | -- The `id()` is the stable string used in saved connection files. Once shipped, never change it. Pick something obvious and short (`postgres`, `mysql`, `clickhouse`). -- `default_port()` is what the connection dialog pre-fills. -- `DriverError` is a `thiserror` enum in `tablepro-core`. Map underlying crate errors into the variants. Add a new variant only after PR discussion. +A connection-scoped stop holds its connection until it settles because it names the connection, not the statement: released early, a cancel request still in flight would stop the next caller's statement on that connection. `OwnedCalls` waits for the stop up to the end of the grace. A stop that has not settled by then, or that the server never acknowledged, costs the connection rather than risking someone else's statement. -`DatabaseDriver` also has defaulted hooks for engines that break an assumption the app otherwise makes. Override one only when the default is wrong for your engine: +The driver adds its own rules on top. PostgreSQL taints a call whose transaction did not end with an acknowledged COMMIT or ROLLBACK, because deadpool's recycle check passes inside a half-finished transaction. MySQL taints a call stopped between statements, since a `KILL` may be on its way to an idle connection. SQL Server discards a connection whose attention was not acknowledged at a token boundary. -- `ddl_is_transactional()`: the structure editor batches DDL into one transaction when true. False for engines that commit implicitly on every DDL statement. -- `reports_rows_affected()`: the inline-edit Save path reads a zero `rows_affected` on an UPDATE or DELETE as another session having changed the row. Return false if the engine cannot produce a count, or every successful save warns about a lost update. -- `is_file_based()`: the connect dialog hides host, port, TLS, the Authentication group and SSH, and relabels Database to File path. True only for engines that open a local file. -- `supports_integrated_auth()`: the connect dialog shows the Method selector (Password / Windows (Kerberos)) only for drivers returning true, and while Kerberos is selected it hides the username and password rows and sends empty credentials. Return true only if `connect()` maps `AuthMode::Kerberos` onto a real integrated-auth path that reads the ambient Kerberos ticket cache; `connection_service::establish` refuses the mode for every other driver. +### Deciding that the link is lost -If your engine needs a different SQL spelling for a statement the app builds centrally, add the dialect branch in `core::sql_dialect` (`quote_ident`, `placeholder_for`, `build_update`, `build_order_and_pagination`) rather than rewriting the SQL inside the driver. ClickHouse takes `build_update`'s `ALTER TABLE … UPDATE` branch for this reason. +A call that times out or loses its connection may have met a dead route or only a dead backend. Each network driver decides in its `settle`: a stop that never got through means the route is gone; one the server answered means it is not; otherwise a connection of its own answers within the cancel grace. Only a confirmed loss marks the `LinkWatch` lost and turns a statement timeout into `ConnectionLost`. -## 4. Add the crate to the workspace +## 5. Writes and the `WriteLedger` -Edit `linux/Cargo.toml`: +A `WriteBatch` is a `WriteKind` (`RowChanges` or `SchemaChanges`), the `TableRef` it changes, the `GuardedStatement` steps and a `ForeignKeyMode`. A row change runs under the workspace statement timeout where the engine can stop one safely; a schema change never does. Both stay bounded by the call's deadline and cancel. -```toml -[workspace] -members = [ - "crates/app", - "crates/core", - "crates/storage", - "crates/drivers/postgres", - "crates/drivers/mysql", - "crates/drivers/sqlite", - "crates/drivers/clickhouse", # add this -] +Each step is a statement and a `RowGuard`: `None`, `AffectedExactly(n)`, or `ProbeExactly { probe, rows }`, which counts the rows with a `SELECT` before the statement runs. A mismatch means the row is not the one that was loaded, and the batch stops with `RowGuardFailed`. An engine that cannot report an affected count refuses `AffectedExactly` with `GuardUnsupported` before anything is sent. + +The work records each step as it goes: + +```rust +ledger.statement_sending(index); +let affected = execute(step).await?; +ledger.statement_confirmed(index); +// after the last step +ledger.commit_sending(Some(token)); ``` -Run `cargo check --workspace` from `linux/`. The new crate must compile in isolation against `core`. +`WriteLedger::stop_permitted` holds the engine's stop while it would turn a knowable outcome into an unknown one: on a `Transactional` engine once COMMIT is on the wire, on a `StatementByStatement` engine while a statement is in flight. + +When a write is interrupted, `WriteLedger::interrupted(cause, fate)` gives the outcome from what was sent and from `SessionFate` (`RolledBack`, `Discarded`, `RollbackIncomplete { executed }`): -## 5. Register the driver +| Atomicity | How far it got | Outcome | +|---|---|---| +| `Transactional` | COMMIT sent | `CommitOutcomeUnknown { token }` | +| `Transactional` | COMMIT not sent, session rolled back or discarded | `RolledBack` at the statement in flight or the next one | +| `Transactional` | COMMIT not sent, rollback incomplete | `PartiallyApplied { applied: executed }` | +| `StatementByStatement` | nothing in flight, nothing confirmed | `RolledBack` at statement 0 | +| `StatementByStatement` | nothing in flight, some confirmed | `PartiallyApplied { applied: confirmed }`, the next statement `Failed` | +| `StatementByStatement` | one in flight, and nothing confirmed or a commit token held | `CommitOutcomeUnknown { token }` | +| `StatementByStatement` | one in flight, some confirmed, no token | `PartiallyApplied { applied: confirmed }`, that statement `Unknown` | -Edit `crates/app/src/main.rs`: +A discarded session leaves nothing behind on a transactional engine, because a COMMIT that was never written cannot land once the session is gone. `WriteLedger::failed` and `WriteLedger::guard_failed` give the outcome of a statement the server refused and of a guard mismatch, with the same rules. The full table is the `outcome_table` test in `crates/core/src/write_ledger.rs`. -```rust -use tablepro_driver_clickhouse::ClickhouseDriver; +A transactional COMMIT that the server answers with an error rolled back; only a COMMIT with no answer is `CommitOutcomeUnknown`. + +### `resolve_commit` +Where the engine can answer later, the ledger carries a `CommitToken`: + +- `PostgresXid { xid, legacy_txid }`: `pg_current_xact_id()` (or `txid_current()` before PostgreSQL 13), read inside the transaction before COMMIT, and asked back with `pg_xact_status` (or `txid_status`). +- `ClickHouseQuery { query_ids, first_unconfirmed }`: the query id of every statement, looked up in `system.query_log`. + +`resolve_commit` answers with a `CommitResolution`: `Applied`, `NotApplied`, `PartiallyApplied`, `StillRunning` or `Unresolvable`. Where `commit_resolution` is `Unavailable`, SQL Server and SQLite answer `Unresolvable` and MySQL returns `Unsupported`. The app reports `CommitOutcomeUnknown` as unknown and asks the user to reload; it does not call `resolve_commit` yet. + +## 6. `EditorSession` + +An editor tab holds one session for as long as it is open, because the state a user builds there lives on that connection and nowhere else: an open transaction, a temporary table, a session setting. + +- It is never pooled and never reopened behind the user's back. A lost connection is `EditorSessionState::Lost`, and the tab opens a new session on its next run. +- One run at a time: a second `run` fails with `EditorSessionBusy`. +- `run(sql, options, sink)` takes `UserSql` as typed and `RunOptions`: `row_limit`, `deadline` and `cancel`. Dropping the caller is a cancel. +- The app's editor splits a script with the engine's grammar and sends one statement per run, or one `GO` batch on SQL Server. A driver still runs whatever text it is given, a whole script included. +- A statement the server refused ends the run with `RunEnd::Failed` in the `RunSummary`; an `Err` from `run` means the session itself failed. +- `RunSummary` carries `elapsed`, the `RunEnd` (`Completed`, `Failed`, `Cancelled`, `TimedOut { server_cancelled }`) and the `TransactionState` after the run. `Unknown` is an answer of its own: the tab's close guard warns instead of guessing. +- `close` stops a running statement, then closes the connection within `close_timeout`. + +### `ResultSink` and backpressure + +A run streams `ResultEvent`s into a `ResultSink`: + +- `ResultSetStarted { columns }` opens a result set. +- `ColumnsResolved { set_index, columns }` replaces the columns of a set whose types the driver could only learn from its rows (SQLite). +- `Rows` carries a batch of rows, 256 in the shipped drivers. +- `RowLimitReached { retained }` comes once per set, when the limit is passed. +- `ResultSetFinished { retained, total_rows, truncation }` closes the set. `Truncation` is `Complete`, `ServerStopped` or `ReadPastLimit`, and `total_rows` is `None` when the driver did not read the rest. +- `CommandCompleted { rows_affected }` reports a statement that returned no rows; `None` is "not reported", not zero. +- `Notice` passes on a server notice or warning. + +`ResultSink::accept` is awaited before the next rows are read off the wire, so a grid that cannot keep up slows the read instead of growing a queue the size of the result. `SinkFlow::ConsumerGone` ends the run with `RunEnd::Cancelled`, and the driver stops the statement on the server where the engine has a stop for it. `stream_read` uses the same sink and events, without a row limit. + +## The row-limit rule + +`RowLimit` wraps a `NonZeroU64`; `RowLimit::EDITOR_DEFAULT` is 10,000. + +- `query_bound` keeps at most the limit and reads one more row to mark the result truncated. +- An editor run keeps at most the limit in each result set, sends `RowLimitReached` once, and ends the set with `Truncation::ReadPastLimit`. Where the engine allows, the rest of the set is stopped on the server rather than read: a `FETCH` of one row past the limit (PostgreSQL), `sql_select_limit` (MySQL), `KILL QUERY` (ClickHouse). SQLite stops stepping, so the rest is never produced. Otherwise the rows past the limit are counted without being decoded (SQL Server, and MySQL where `sql_select_limit` does not apply). +- The limit never ends the script, the user's transaction or the statements after the limited one. The conformance case `row_limit_keeps_transaction_and_later_statements` checks this on every engine with editor transactions. +- The driver never rewrites the user's SQL to add a `LIMIT`. +- `stream_read` has no limit. + +## 7. Dialects + +`Connection::dialect()` returns the engine's `SqlDialect`, built from `ServerInfo` at connect. Every builder in `tablepro-core` (pages, filters, row edits, exports) goes through it: quoting, placeholders, literals, the page window, keyset comparison, the engine's row address and type parsing. A dialect never reads session state. `SqlDialect::ddl()` returns the engine's `DdlDialect`, or `None` where the app does not edit the engine's structure. Never match on a driver id in core; add a dialect method. + +## 8. Registration + +Add the crate to `crates/app/Cargo.toml` and register it in `build_registry` in `crates/app/src/lib.rs`: + +```rust fn build_registry() -> DriverRegistry { let mut r = DriverRegistry::new(); - r.register(Arc::new(drivers_postgres::PgDriver)); + r.register(Arc::new(drivers_clickhouse::ClickhouseDriver)); + r.register(Arc::new(drivers_mssql::MssqlDriver)); r.register(Arc::new(drivers_mysql::MysqlDriver)); + r.register(Arc::new(drivers_postgres::PgDriver)); r.register(Arc::new(drivers_sqlite::SqliteDriver)); - r.register(Arc::new(ClickhouseDriver)); // add this r } ``` -Update `crates/app/Cargo.toml` to depend on the new driver crate. **This step is the one most often forgotten.** The driver crate compiles fine without it; the app simply does not know the driver exists. +Without this step the crate compiles and the app never offers it. -## 6. Tests +## 9. Tests -Two test layers, both required for merge: +The layout every driver follows: -**Unit tests** — in `src/lib.rs` `#[cfg(test)]` module. Exercise pure logic: SQL builders, type mappers, error mapping. Do not require a running database. +- Unit tests next to the code, for pure logic: the dialect, error mapping, decoders, statement classification. +- `tests/integration/main.rs` with one module per topic: `support.rs` (start a fixture, connect, run), `conformance_engine.rs`, `tls.rs` for a network engine, and the engine's own behaviour (`metadata.rs`, `write_batch.rs`, `cancellation.rs` and so on). A network engine's test starts its own container and is `#[ignore = "requires docker"]`. +- A container fixture in `crates/test-fixtures` for anything the default image cannot do: TLS from the generated PKI, a second principal that logs in with a client certificate. -**Integration tests** — in `tests/integration.rs`. Use [testcontainers-rs](https://crates.io/crates/testcontainers) to spin up a real instance: +The conformance suites are the contract's own tests. The driver's `conformance_engine.rs` implements `ConformanceEngine` and the engine traits its flags need, then invokes: ```rust -use testcontainers::clients::Cli; -use testcontainers::images::generic::GenericImage; - -#[tokio::test] -async fn list_tables_returns_seeded_tables() { - let docker = Cli::default(); - let image = GenericImage::new("clickhouse/clickhouse-server", "latest") - .with_exposed_port(8123); - let node = docker.run(image); - let port = node.get_host_port_ipv4(8123); - - let driver = ClickhouseDriver; - let conn = driver.connect(ConnectOptions { - host: "127.0.0.1".into(), - port, - username: "default".into(), - password: "".into(), - database: "default".into(), - ..Default::default() - }).await.unwrap(); - - conn.execute("CREATE TABLE foo (id Int32) ENGINE=Memory").await.unwrap(); - let tables = conn.list_tables().await.unwrap(); - assert!(tables.iter().any(|t| t.name == "foo")); +tablepro_test_support::connection_conformance_suite! { + engine: PgEngine, + docker: true, + endpoint: network, + pool: pooled, + editor_transactions: true, + unsupported_types: true, + bounded_ddl: true, +} + +tablepro_test_support::tls_conformance_suite! { + engine: PgTlsEngine, + client_identity: true, } ``` -Integration tests run in CI on the Linux runner, gated behind `--ignored` so contributors without Docker can still run `cargo test`. +The cases cover listing and identity, deadlines within the cancel grace, a cancelled editor run, a dropped caller stopping the server's statement, a lost transport, a busy pool's probe, read-only refusal, write atomicity, row guards, the row limit, unbounded schema changes, streaming in bounded batches, type fallback and every TLS mode. [testing.md](testing.md#conformance) lists them with their flags. `suite_flags_match_driver_capabilities` runs without Docker and fails when a flag disagrees with the driver's capabilities, so a case cannot be skipped by declaring less. + +Behaviour the suites do not cover gets a test of its own against the real engine: the engine's stop, its error mapping, what a pooler does to it, each write outcome the ledger can produce. -## Checklist for the PR +For a new package with Docker tests, add a test group and override in `.config/nextest.toml` and an entry in the `integration` matrix of `.github/workflows/build-linux.yml`. Run them one package at a time: + +```bash +cargo nextest run --locked --profile ci -p tablepro-driver- --run-ignored only +``` -- [ ] New crate at `crates/drivers//` compiles in isolation -- [ ] `DatabaseDriver` and `Connection` fully implemented (no `todo!()` in any method) -- [ ] Crate added to workspace `members` -- [ ] Driver registered in `app::build_registry` -- [ ] App `Cargo.toml` depends on the new driver crate -- [ ] At least one unit test for type / error mapping -- [ ] At least one integration test using testcontainers -- [ ] PR description includes the engine version tested against -- [ ] No new dependencies in `core` or `storage` crates -- [ ] `cargo clippy --all -- -D warnings` clean +## Checklist + +- [ ] The client library is on crates.io, unforked, and uses aws-lc-rs. +- [ ] `capabilities()` matches what the engine does; the suite flags agree. +- [ ] Every stream comes from the transport, and TLS checks `tls_server_name()`. +- [ ] Every call runs under `OwnedCalls` with the engine's stop, and a connection-scoped stop never releases an unsettled connection. +- [ ] `probe` never waits for a pool slot; `stream_read` never holds one. +- [ ] Saves record every step in a `WriteLedger`, and the stop is held while a commit is in flight. +- [ ] Editor sessions are one connection per tab, one run at a time, with `ResultSink` backpressure and the row-limit rule. +- [ ] Errors map to `DriverError` by the engine's code ([error-handling.md](error-handling.md)). +- [ ] Registered in `build_registry`; added to the workspace and to `crates/app/Cargo.toml`. +- [ ] Both conformance suites run; the CI matrix and nextest groups include the package. +- [ ] A page in `docs/drivers/` covers timeouts, stops, read-only, writes, trust and the engine's limits. +- [ ] `cargo clippy --all-targets -- -D warnings` passes with and without default features. From 24c2d9e685b0261c691d3bba9131ca7c694da6ea Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:15:41 +0700 Subject: [PATCH 003/178] docs(linux): document driver errors, timeouts and TLS remedies --- linux/docs/error-handling.md | 226 ++++++++++++++++++++--------------- 1 file changed, 131 insertions(+), 95 deletions(-) diff --git a/linux/docs/error-handling.md b/linux/docs/error-handling.md index 7e960ab412..40cdef2089 100644 --- a/linux/docs/error-handling.md +++ b/linux/docs/error-handling.md @@ -1,131 +1,167 @@ # Error handling -Two error styles, applied per layer. Mixing them is a review red flag. +Errors are typed enums, built with `thiserror` wherever they cross a crate boundary. There is no `anyhow` in the workspace: the caller matches on the enum, and the app decides what to say from it, never from the message. -## Rule +## Errors by layer -| Layer | Error type | Why | +| Crate | Error types | Notes | |---|---|---| -| `core` (traits, contracts) | `thiserror` enums | Stable variants; consumers match on them. | -| `core::DriverError`, `storage::StorageError` | `thiserror` enums | Cross crate boundaries; need exhaustive matching. | -| `drivers/` | `thiserror` enum mapping the underlying crate's error into `core::DriverError` | Underlying crate's errors do not leak. | -| `storage` (internal) | `thiserror` enum, `StorageError` | Same reasoning. | -| `app` (UI handlers, services, internal glue) | `anyhow::Result` | Composition. Errors are mostly displayed and dropped. | -| Tests | `anyhow::Result` or `?` against domain errors | Whatever is shortest. | +| `tablepro-core` | `DriverError`, `ConfigError`, `TransportError`, `SshFailure`, `CertificateFileProblem` | The contract every driver and transport speaks. | +| `tablepro-net` | `tls::TlsSetupError` | Wraps a `ConfigError` or a rustls error while a TLS config is built; drivers turn it into `DriverError`. | +| `tablepro-ssh` | `SshFailure`, inside `TransportError::Ssh` | Classified from OpenSSH's exit status and stderr. | +| `drivers/` | `DriverError` only | The client library's error never leaves the crate. | +| `tablepro-storage` | `StorageError`, `SettingsError`, `KeyringError`, `CredentialError` | | +| `tablepro-session` | `ConnectError`, `OperationError`, `ClaimError` | `ConnectError::Driver` and `OperationError::Driver` carry the driver's error unchanged. | +| `tablepro` (the app) | Small enums per module (`StartupError`, `LoggingError`, `AcquireError`, ...) | User-facing text comes from `crates/app/src/ui/error_text/`. | + +## `DriverError` + +`DriverError` is `#[non_exhaustive]`, `Clone` and `PartialEq`, so a result can be stored, compared in tests and sent across threads. Each variant has one meaning: + +| Variant | Meaning | +|---|---| +| `Transport(TransportError)` | The route failed: name resolution, refused, unreachable, a connect or SSH timeout, or an `SshFailure`. | +| `Auth { diagnostics }` | The server refused the credentials, with its own explanation when it gave one. | +| `IntegratedAuth(String)` | A Kerberos login failed before the server saw it. | +| `Tls { failure, detail }` | The handshake failed; `TlsFailure` says why. | +| `Server(ServerDiagnostics)` | The server refused the statement. | +| `ReadOnly(ReadOnlyRefusal)` | A write on a read-only connection: `ClientGuard` (the driver refused before sending) or `ServerRejected` (the server refused). | +| `Cancelled` | The caller stopped it. | +| `Timeout { phase, server_cancelled }` | A bound passed. `server_cancelled` says the server confirmed it stopped the statement. | +| `ConnectionLost { during }` | The connection went away; `LossPhase` says while doing what. | +| `RolledBack`, `RowGuardFailed`, `CommitOutcomeUnknown`, `PartiallyApplied` | What a failed save left in the database ([adding-drivers.md](adding-drivers.md#5-writes-and-the-writeledger)). | +| `GuardUnsupported` | The engine reports no row count, so a guarded write cannot run. | +| `FileNotFound`, `FileAccessDenied`, `NotADatabase`, `FileIo` | A file engine's file. | +| `Busy` | The server has no room for another connection, or the pool had no free slot in time. | +| `EditorSessionBusy` | The tab's session is already running a statement. | +| `Config(ConfigError)` | The settings cannot be used: a bad host, port, path, TLS option, auth mode or missing database name. | +| `Decode { column, detail }` | The driver could not read a value. A bug on our side, not the server's. | +| `Protocol(String)` | The server or the client library said something the driver did not expect. | +| `Unsupported { feature }` | The engine has no such feature. | -`anyhow` is fine for a function that wraps several different error sources and forwards them to a UI dialog or a log line. It is wrong for a public API that callers must reason about. +Rules: -## `thiserror` patterns +- Add a variant only when the app has to act on it differently from every existing one. A new message is not a reason. +- Never wrap a `Box` or the client library's error. Keep what the app needs: a code, a phase, a path. +- The `#[error]` text is for logs. The UI writes its own sentence from the variant. -Domain errors are exhaustive enums: +## `ErrorCategory` -```rust -#[derive(Debug, thiserror::Error)] -pub enum DriverError { - #[error("connection refused")] - ConnectionRefused, +`DriverError::category()` (and `TransportError::category()`, `SshFailure::category()`) sorts every error into the kind of problem it is. The app branches on the category, so an error a driver adds later still lands in a branch that says what to do next. - #[error("authentication failed")] - AuthFailed, +| Category | Variants | What the app does | +|---|---|---| +| `Configuration` | `Config`, `Unsupported`; SSH setup failures | Names the setting to change. The session stops retrying until the user changes something. | +| `Network` | `Transport` (resolution, refused, unreachable); a rejected SSH forward | Names the host and port. A session retries on a backoff. | +| `Tls` | `Tls` | One sentence per `TlsFailure`, with a remedy button where one exists. The session stops retrying. | +| `Authentication` | `Auth`, `IntegratedAuth`; SSH auth and host-key failures | Says the login or the host key was refused, with the server's reason. Never echoes what was typed. The session stops retrying. | +| `ReadOnly` | `ReadOnly` | Says who refused: this connection's setting, or the server. | +| `Server` | `Server` | The server's own words, code, detail and hint. | +| `Interrupted` | `Cancelled` | "Stopped." | +| `Timeout` | `Timeout`, `TransportError::Timeout` | One sentence per `TimeoutPhase`. | +| `ConnectionLost` | `ConnectionLost` | Says the connection closed. The session reconnects on a backoff. | +| `WriteOutcome` | `RolledBack`, `RowGuardFailed`, `CommitOutcomeUnknown`, `PartiallyApplied` | Says what is in the database now. Only `RolledBack` and `RowGuardFailed` may say nothing changed. | +| `File` | the four file variants | Names the path. A connect that failed on the file is not retried. | +| `Busy` | `Busy`, `EditorSessionBusy` | Says to try again shortly, or to wait for the tab's statement. | +| `Internal` | `GuardUnsupported`, `Decode`, `Protocol`; `SshFailure::Protocol` | Says what the driver could not do: check a save, read a value, read the reply. | + +## `ServerDiagnostics` + +A server error keeps the shape the server sent it in. `ServerCode` holds each engine's own numbering, because the number is what tells a missing password apart from a missing table: + +| `ServerCode` | Engine | Shown as | +|---|---|---| +| `SqlState(String)` | PostgreSQL | SQLSTATE 23505 | +| `MySql { number, sqlstate }` | MySQL, MariaDB | MySQL error 1062 | +| `Tds { number, state, class }` | SQL Server | SQL Server error 2627 | +| `ClickHouse { code }` | ClickHouse | ClickHouse code 62 | +| `Sqlite { primary, extended }` | SQLite | SQLite error 2067 | - #[error("TLS handshake failed: {0}")] - Tls(String), +`ServerDiagnostics` also carries `severity`, `detail`, `hint`, `position`, `where_context`, `schema`, `table`, `column` and `constraint`. PostgreSQL fills most of them; the other engines mostly leave them empty. `position` counts characters from the start of the statement the user wrote: a driver that wraps the statement corrects it (the PostgreSQL cursor flight subtracts its `DECLARE` prefix). - #[error("query failed: {message}")] - Query { message: String, sqlstate: Option }, +A driver maps the codes the app acts on to their own variants and passes everything else through as `Server`: - #[error("connection closed unexpectedly")] - Disconnected, +| Meaning | PostgreSQL | MySQL, MariaDB | SQL Server | ClickHouse | SQLite | +|---|---|---|---|---|---| +| `Auth` | 28P01, 28000 | 1045 | 18456, 18452, 18486-18488 | 192, 193, 194, 516; 497 at login | | +| `ReadOnly` (server) | 25006 | 1792 | 3906 | 164, 392 | 8 (`SQLITE_READONLY`) | +| Statement `Timeout` | 57014 with "statement timeout" | 3024 (MySQL), 1969 (MariaDB) | | 159 | | +| `LockWait` timeout | 55P03 | 1205 | 1222 | | | +| `Cancelled` | 57014 after our cancel request | 1317 | attention acknowledged | 394 after our KILL | 9 (`SQLITE_INTERRUPT`) after our stop | +| `Busy` | 53300 | 1040 | 17809 | 202, 203 | 5, 6 (`SQLITE_BUSY`, `SQLITE_LOCKED`) | +| `ConnectionLost` | 57P01, 57P02, 57P03 | 1053, 1927, 4031 | | | | - #[error("driver internal error: {0}")] - Internal(String), -} -``` +The engine pages ([PostgreSQL](drivers/postgresql.md), [MySQL and MariaDB](drivers/mysql.md), [SQLite](drivers/sqlite.md), [SQL Server](drivers/sql-server.md), [ClickHouse](drivers/clickhouse.md)) give each driver's mapping, and the places where a driver can only read the message, as SQL Server's TLS failures through tiberius. -Rules: +## `TimeoutPhase` -- Variants are stable. Once shipped, do not rename or remove. Add new variants at the end. -- Avoid wrapping arbitrary `Box` inside variants. Map underlying errors into specific variants. The `Internal(String)` variant is the escape hatch for cases that genuinely cannot be classified — use it sparingly. -- The `#[error]` message is for logs and developer-facing surfaces. The UI builds its own message based on the variant. - -## Driver-side error mapping - -Each driver crate maps the underlying crate's errors: - -```rust -fn map_sqlx_error(err: sqlx::Error) -> DriverError { - use sqlx::Error::*; - match err { - Database(e) => DriverError::Query { - message: e.message().to_string(), - sqlstate: e.code().map(|c| c.to_string()), - }, - Io(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => DriverError::ConnectionRefused, - Tls(e) => DriverError::Tls(e.to_string()), - PoolClosed | PoolTimedOut => DriverError::Disconnected, - other => DriverError::Internal(format!("{other}")), - } -} -``` +`Timeout` and `TransportError::Timeout` name the phase that ran out, because each phase has a different cause and a different fix: -The driver does not pass through `sqlx::Error` to callers. Callers see only `DriverError`. +| Phase | Bound | Meaning | +|---|---|---| +| `Connect` | `LivenessPolicy::connect_timeout`, 10 s | No TCP connection, or the socket did not open. | +| `Login` | `login_timeout`, 15 s | Connected, but the login or TLS handshake did not finish. | +| `SshHandshake` | `ssh_handshake_timeout`, 20 s, paused while a prompt is open | OpenSSH did not authenticate in time. | +| `SshChannelOpen` | `ssh_channel_open_timeout`, 10 s | `ssh -O forward` did not answer in time. | +| `LockWait` | `WorkspaceSettings::lock_timeout`, 10 s | Another session holds a lock this statement needs. | +| `Statement` | The call's deadline, or the workspace statement timeout of 60 s | The statement ran too long. `server_cancelled` says whether the server confirmed the stop. | +| `Probe` | `probe_timeout`, 5 s | The server did not answer a liveness check. | +| `CancelAcknowledgement` | `cancel_grace`, 5 s | The server did not confirm a stop. No shipped driver raises it: a stop the server never confirms ends as an abandoned call, `ConnectionLost`. | -## UI display +## `TlsFailure` and its remedies -`app` translates `DriverError` and `StorageError` into user-facing messages with full context. The mapping lives in `app::ui::error_message`: +Drivers classify a handshake failure with `tablepro_net::tls::classify`, which walks the error's sources to the rustls error. SQL Server reads rustls's message text instead, because tiberius keeps only the text. Where the driver can tell, a failure before the server agreed to TLS is `ServerRefusedTls`, never a certificate problem. -```rust -fn message_for(err: &DriverError) -> String { - match err { - DriverError::ConnectionRefused => "Could not reach the database. Is it running?".into(), - DriverError::AuthFailed => "Username or password is wrong.".into(), - DriverError::Tls(detail) => format!("TLS handshake failed: {detail}"), - DriverError::Query { message, sqlstate: Some(s) } => format!("Query failed (SQLSTATE {s}): {message}"), - DriverError::Query { message, .. } => format!("Query failed: {message}"), - DriverError::Disconnected => "The connection was closed. Try reconnecting.".into(), - DriverError::Internal(detail) => format!("Internal driver error: {detail}"), - } -} -``` +| `TlsFailure` | Cause | What the app offers | +|---|---|---| +| `UnknownIssuer` | The certificate's issuer is not in the trust set. | **Choose CA Certificate…**, which opens the connect dialog at the CA file row. | +| `NameMismatch` | The certificate is for another name. | **Set TLS Server Name**, which opens the connect dialog at the server name row. | +| `Expired`, `NotYetValid`, `Revoked` | The certificate itself is bad. | Nothing: the server's certificate has to be replaced. | +| `ServerRefusedTls` | The server does not speak TLS on this port. | The message; turn TLS off or use the TLS port. | +| `ServerRequiresTls` | TLS was off and the server insists on it. | The message; turn TLS on. | +| `HandshakeRejected` | The server ended the handshake, for example over the client certificate. | The server's reason. | +| `CaFileInvalid`, `ClientIdentityInvalid` | A file the user chose cannot be used. | The message names the file. | +| `Other` | Anything else. | The rustls message. | + +The buttons are `TlsRemedy` in `crates/app/src/ui/error_text/tls_remedy.rs`, shown on the connect failure's toast. Which certificates each engine trusts, and how to choose a CA file under Flatpak, is in [ARCHITECTURE.md](../ARCHITECTURE.md#trust). + +## Mapping errors in a driver + +- Map by the engine's code, not by message text. Where the client library keeps nothing but the text, say so in a comment and pin the text with a test. +- Keep the server's diagnostics: `DriverError::reported(ServerDiagnostics)`, and `DriverError::auth(Some(diagnostics))` for a refused login. +- An error the engine raised to acknowledge our stop is the stop: `Cancelled`, or `Timeout { server_cancelled: true }`. `EngineStop::acknowledged` is where `OwnedCalls` learns which errors those are. +- A `ConnectionLost` goes through `Transport::explain_closed_stream`, so an SSH master that exited is reported as that. +- A dead route is reported only once a check outside the pool confirms it ([adding-drivers.md](adding-drivers.md#deciding-that-the-link-is-lost)). -Do not display raw `Debug` or `Display` output for domain errors. Always go through this layer. +## User-facing text + +`crates/app/src/ui/error_text/` turns errors into sentences: `driver_message` for a `DriverError`, `connect_error_message` and `operation_error_message` for the session's errors, and an SSH module for `SshFailure`. Every string goes through `crate::i18n`. Never show `Debug` or the `#[error]` text of an error on its own. ## Logging -Use the `tracing` crate. `app::logging::init` installs a `tracing_subscriber` that writes to **stderr**, which the GNOME session journals for both Flatpak and system installs. Panic output goes to the same place, so a panic and the log lines around it stay in order. There is no `tracing-journald` writer and no custom panic hook. +Logging goes through `tracing`. `crates/app/src/logging.rs` installs a `tracing_subscriber` fmt layer that writes to **stderr**, which the GNOME session journals for both Flatpak and system installs. Panics land in the same stream, so a panic and the lines around it stay in order. There is no journald writer and no panic hook. -The filter comes from `RUST_LOG`. Without it the level follows the build profile: `debug` for a development build, `info` otherwise. A `RUST_LOG` that does not parse falls back to the profile default and logs a warning saying so, rather than silently dropping to the default. +- The filter comes from `RUST_LOG`. Without it, a development build logs at `debug` and an installed one at `info`. A `RUST_LOG` that does not parse falls back to that default and logs a warning saying so. +- `TABLEPRO_LOG_FORMAT=json` writes one JSON object per line; `text`, the default, writes plain lines. ```sh RUST_LOG=debug ./_build/crates/app/tablepro -RUST_LOG=tablepro_app::ui=trace,info ./_build/crates/app/tablepro +RUST_LOG=tablepro=trace,info TABLEPRO_LOG_FORMAT=json ./_build/crates/app/tablepro journalctl --user -f -t tablepro ``` -Levels: - -- `error!` — something the user must see, or a contract was violated. -- `warn!` — recoverable but suspicious. -- `info!` — significant lifecycle events: app start, driver registered, connection opened. -- `debug!` — verbose internal flow. -- `trace!` — query bodies, network frames. Off by default. - -Never log passwords, secret tokens, or full query parameters at any level above `trace!`. The lint enforces this in CI by grepping for known sensitive identifiers. - -## `unwrap` and `expect` +Levels: `error!` for what the user must see or a broken contract, `warn!` for recoverable but suspicious, `info!` for lifecycle events, `debug!` for internal flow, `trace!` for query text and wire detail. -Banned in production paths. The only legitimate uses: +Never log a password, a secret, a key or a bound value. sqlx's own statement logging is turned off in the MySQL and SQLite drivers, and a test in each crate (`tests/statement_logging.rs`) proves no statement text reaches the subscriber. -- `OnceLock::get_or_init` initialisers that genuinely cannot fail. -- Test code. -- Single-call type conversions on values whose validity is locally provable (e.g. `"5432".parse::().expect("constant literal")`). +## `unwrap`, `expect` and `panic!` -In every other case, propagate the error. If a function "cannot fail", make it `infallible` by typing. +The workspace lints deny `unwrap_used`, `expect_used`, `panic`, `todo` and `unimplemented`; `clippy.toml` allows them in tests only. Return a real error instead. The one accepted use outside tests is in a `const`, where the compiler proves the value, as `RowLimit::EDITOR_DEFAULT` does. -## Anti-patterns flagged in review +## Patterns refused in review -- `Result>` in a public function. Use a `thiserror` enum. -- `anyhow::Error` returned from `core` or `storage`. Those crates expose typed errors only. -- `unwrap()` after a `Result` from a fallible operation. Always handle or propagate. -- `match err { _ => "Something went wrong" }`. Always exhaustive. -- A `String` error type. We have one shipped product; use the proper enum. +- A `String` or `Box` as an error type in a public function. +- A client library's error in a public signature of a driver crate. +- `match error { _ => "Something went wrong" }`. Match the category, then the variant. +- A retry loop around an error the category says retrying cannot fix: `Configuration`, `Authentication`, `File`, `WriteOutcome`. +- Treating `CommitOutcomeUnknown` or `PartiallyApplied` as a failure that changed nothing. From 90c670c834e4f52fb91f35c489217a54af7d05ec Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:15:43 +0700 Subject: [PATCH 004/178] docs(linux): add per-engine driver pages --- linux/docs/drivers/clickhouse.md | 102 ++++++++++++++++++++++ linux/docs/drivers/mysql.md | 107 +++++++++++++++++++++++ linux/docs/drivers/postgresql.md | 144 +++++++++++++++++++++++++++++++ linux/docs/drivers/sql-server.md | 103 ++++++++++++++++++++++ linux/docs/drivers/sqlite.md | 76 ++++++++++++++++ 5 files changed, 532 insertions(+) create mode 100644 linux/docs/drivers/clickhouse.md create mode 100644 linux/docs/drivers/mysql.md create mode 100644 linux/docs/drivers/postgresql.md create mode 100644 linux/docs/drivers/sql-server.md create mode 100644 linux/docs/drivers/sqlite.md diff --git a/linux/docs/drivers/clickhouse.md b/linux/docs/drivers/clickhouse.md new file mode 100644 index 0000000000..a6796aae24 --- /dev/null +++ b/linux/docs/drivers/clickhouse.md @@ -0,0 +1,102 @@ +# ClickHouse + +Driver id `clickhouse`, crate `crates/drivers/clickhouse`. It speaks ClickHouse's HTTP interface through the official `clickhouse` crate 0.15 with its default features off, on a hyper-util client whose connector dials through the transport and whose TLS is hyper-rustls with the driver's own rustls config. The integration suite runs against ClickHouse 25.8. The contract it implements is in [adding-drivers.md](../adding-drivers.md). + +The default port is 8123, the plain HTTP port. With TLS on, use the server's HTTPS port (8443 by default); a TLS connect to the HTTP port fails. + +## Requests and connections + +HTTP has no login and no long-lived session, so there is no pool of logins: + +- hyper keeps idle HTTP connections for 2 s, shorter than the server's keep-alive (3 s before 23.11, 10 s after), so a reused connection is never one the server is about to close. +- Every connection is a stream from `Transport::open`. The URL names only the service, for the `Host` header and the TLS name, so an SSH forward never needs a DNS lookup of a name only the far side can resolve. +- The connect is the first request, `SELECT version(), currentDatabase()`, within the connect and login timeouts together. A refused password or a failed handshake shows up there. +- `probe` is one more request, `SELECT 1`, on the control client. + +Three clients share that connection pool: + +| Client | Used for | Settings beyond the common ones | +|---|---|---| +| Workspace | Catalogue reads, pages, counts, exports, write probes, editor sessions | `readonly=2` and `allow_ddl=0` on a read-only connection | +| Writes | The statements of a save | `wait_end_of_query=1`, `max_execution_time=0` | +| Control | `KILL QUERY`, `SYSTEM FLUSH LOGS`, commit resolution | None. It is not held read-only, because a `KILL` is not a read, and it runs only SQL the driver wrote. | + +Every request carries these settings: + +| Setting | Why | +|---|---| +| `output_format_decimal_trailing_zeros=1` | A Decimal keeps the scale it was stored with. | +| `http_write_exception_in_output_format=0` | An error after the first rows arrives as exception text, not as one more row. | +| `output_format_json_quote_denormals=1` | NaN and infinity arrive as words instead of null. | +| `output_format_json_validate_utf8=0` | A String that is not UTF-8 arrives byte for byte. | +| `mutations_sync=1` | A save waits for its mutation, so the grid refetches what the save wrote. | +| `cancel_http_readonly_queries_on_client_close=1` | A read whose HTTP connection the app dropped stops on the server even when no `KILL` got through. | + +The client names itself to the server as TablePro with the app's version (`with_product_info`). + +Results stream as `JSONCompactEachRowWithNamesAndTypes`, and each cell is read by the type the server named for it. Values go to the server as typed `{pN:Type}` parameters, never spliced into the SQL. SQL the user typed goes through `query_raw`, so a `?` in it stays a `?`. + +## TLS + +- `VerifyFull` trusts the system store plus the connection's CA file ([Trust](../../ARCHITECTURE.md#trust)) and checks the name from `NetworkTarget::tls_server_name()`. `Require` encrypts without checking the certificate. +- With a client certificate set, the driver sends `X-ClickHouse-SSL-Certificate-Auth: on` and no password, and the server logs the user in by the certificate's common name (`ssl_certificates` in the user's configuration). +- The crate's own TLS features stay off, so there is one TLS stack. + +## Read-only + +`ReadOnlyEnforcement::EngineEnforced`. A read-only connection sends `readonly=2` and `allow_ddl=0` with every workspace and editor request, and the server refuses a write whatever the SQL says (error 164, or 392 for DDL). `readonly=2` still lets a session change settings, which the editor needs. Every workspace read also carries `readonly=2`, on any connection, with its time bound. Saves on a read-only connection are refused before anything is sent. + +## Stops and timeouts + +A read carries `max_execution_time` (the nearer of the call's deadline and the workspace's 60 s, in whole seconds rounded up) with `timeout_overflow_mode=throw`, so the server stops it on time on its own (error 159). + +A stop is `KILL QUERY WHERE query_id = ? ASYNC` on the control client. Each query the driver sends carries a query id of its own, and the id is bound by the client, never written into the SQL. The `KILL` names that one query, so a late one cannot hit the next: `StopDelivery::OutOfBand(StopScope::Statement)`. The server answers the stopped query with error 394, which reads as `Cancelled`, or as `Timeout { server_cancelled: true }` when the deadline asked for it. + +| Server answer | Reported as | +|---|---| +| 394 after our `KILL` | `Cancelled` | +| 159 | `Timeout { phase: Statement, server_cancelled: true }` | +| 164, 392 | `ReadOnly(ServerRejected)` | +| 192, 193, 194, 516; 497 on the first request | `Auth` | +| 202, 203 | `Busy` | + +## Writes + +ClickHouse has no transactions: `WriteAtomicity::StatementByStatement` for row and schema changes. A row change is an `INSERT` or a mutation (`ALTER TABLE ... UPDATE` or `DELETE`), and a mutation is neither atomic nor reversible. + +1. Every probe runs first, so a row that moved stops the save before any mutation is sent. +2. Then each statement runs to its end on the writes client, under a query id of its own. `mutations_sync=1` makes it wait for the mutation, and `max_execution_time=0` means no server-side limit, because a mutation the server stops waiting for keeps running anyway. +3. A statement in flight is never killed: a `KILL` would not undo what the mutation already changed, only hide it. A stop is honoured between statements. + +The grid names a ClickHouse row by its sorting key (`RowIdentity::SortingKey`), which may match several rows. So a save proves its row with a probe before the mutation, and a batch with an exact affected-row guard is refused with `GuardUnsupported`: a mutation reports no row count. An `INSERT` reports its written rows; a mutation reports none, which is "not known", not zero. A table with no sorting key is read-only in the grid. The row-count estimate is `system.tables.total_rows`, which MergeTree keeps exact. + +A failed statement is sorted by whether it can have landed. A server error that refused it (a syntax error, an unknown column) did not apply. A cancel, a timeout, an unfinished mutation wait (341), a network error, or a body with no error code may have applied, so the outcome comes from the `WriteLedger`, with a `CommitToken::ClickHouseQuery` of every statement's query id. + +### Resolving an unknown outcome + +`CommitResolutionSupport::QueryLog`. `resolve_commit` asks about the first statement the save could not confirm: + +1. `SYSTEM FLUSH LOGS`, so the log has the latest entries. A login without the privilege skips it. +2. That query id in `system.query_log` from today and yesterday: + +| What the log says | `CommitResolution` | +|---|---| +| `QueryFinish` | `Applied` | +| `ExceptionBeforeStart`, not an `ALTER` | `NotApplied` | +| `ExceptionWhileProcessing` on an `INSERT` | `Unresolvable`: some blocks may have been written | +| An `ALTER` that failed while its mutation may live on (cancelled, timed out, the wait gave up, or failed while processing) | The mutation, below | +| Started, never ended | `StillRunning` if `system.processes` still lists it, else `Unresolvable` | +| No `system.query_log`, or no access to it | `Unresolvable` | + +3. For a mutation: `system.mutations` has no column that links a mutation to its `ALTER`, so the driver looks for mutations on the table the log names, created between the `ALTER`'s start and end. One that is done is `Applied`; one still running is `StillRunning`. None, because someone ran `KILL MUTATION` (which removes the row and keeps the parts already changed), or more than one, is `Unresolvable`. +4. The batch's answer: statements confirmed before the unconfirmed one, or not yet sent after it, turn a whole answer into `PartiallyApplied`. + +The query log and the process list are each node's own, and the driver asks the node its connection reaches; it does not query the cluster. Behind a load balancer that sends the next request to another node, that node's log has no entry for the statement and the answer is `Unresolvable`. + +## Editor sessions + +- An editor session is the workspace client with a `session_id` of its own and `session_timeout=3600`, so the server keeps the session's settings and temporary tables between runs. The server runs one query at a time per session, so a second run is `EditorSessionBusy`. +- One statement per run (`ScriptExecution::SingleStatement`), each with a new query id. +- The run's deadline, or the session's statement timeout, goes to the server as `max_execution_time` with `timeout_overflow_mode=throw`. A stop sends `KILL QUERY` and keeps reading, because the server answers the `KILL` by ending the response; a server that has not answered within the cancel grace is cut off. +- Past the row limit the driver sends `KILL QUERY` for the rest of the result rather than reading it. +- ClickHouse has no transaction an editor statement can leave open, so the transaction state is always `Idle`. diff --git a/linux/docs/drivers/mysql.md b/linux/docs/drivers/mysql.md new file mode 100644 index 0000000000..7380e8577a --- /dev/null +++ b/linux/docs/drivers/mysql.md @@ -0,0 +1,107 @@ +# MySQL and MariaDB + +Driver id `mysql`, crate `crates/drivers/mysql`. One driver serves both servers through sqlx 0.9 (`mysql`, `mysql-rsa`, `tls-rustls-aws-lc-rs`) with a deadpool 0.13 pool. `MySqlFlavour` tells them apart from `VERSION()` (MariaDB says so in it), because they name their timeouts, error numbers and catalogue columns differently. The integration suite runs against MySQL 8.4 and MariaDB 11.4. The contract it implements is in [adding-drivers.md](../adding-drivers.md). + +A connection needs a database name (`database_required`). + +## Connections + +| Connection | Used for | Session | +|---|---|---| +| Workspace pool, 4 connections (`WORKSPACE_POOL_MAX_SIZE`) | The sidebar, the grid, counts, saves | Workspace | +| One login per call | `stream_read` (exports), and `probe` when every pool slot is busy | Workspace without a statement timeout, or Control | +| One connection per editor tab | `EditorSession` | Editor | +| One control connection per workspace and per editor session | `KILL QUERY` | Control: nothing changed | + +A pool member is handed out again only after it answers a ping within the probe timeout. + +## Route + +sqlx dials its own sockets and takes no stream, so the driver passes it the transport's route: + +- A direct route is the host and port, dialled by sqlx. +- An SSH forward is the Unix socket OpenSSH listens on. sqlx connects to the socket, and its `host` carries the name TLS checks: the service's own, or the override. + +Nothing relays bytes in between. The whole login, dial included, is bounded by the connect and login timeouts together (25 s). sqlx 0.9 sets no `TCP_NODELAY` and no keepalive on its own dial, so on a direct route a dead peer is found by the probe and by each call's deadline, not by the kernel. + +## Session statements + +Every workspace and editor session starts the same way, so a statement means the same thing on each: + +```sql +SET NAMES utf8mb4 +SET SESSION TRANSACTION READ ONLY -- read-only connections only +SET SESSION max_execution_time = 60000 -- MySQL, milliseconds +SET SESSION max_statement_time = 60 -- MariaDB, seconds +SET SESSION lock_wait_timeout = 10 +SET SESSION innodb_lock_wait_timeout = 10 +``` + +- `SET NAMES utf8mb4` names no collation, so the server picks its default for the charset: MySQL's `default_collation_for_utf8mb4`, MariaDB's `character_set_collations`. That is what the mysql client gets too. +- The statement timeout is the workspace's 60 s. MySQL's `max_execution_time` bounds only read-only `SELECT`s; MariaDB's `max_statement_time` bounds every statement, which is why a schema change lifts it (below). An editor session sets it only when the session has a statement timeout of its own. +- Lock waits get the workspace's 10 s, in whole seconds rounded up. +- Numbers are sent as integers, never floats a server could read in another locale. + +A workspace session also pins two things the app's generated SQL depends on: `time_zone = '+00:00'`, so a `TIMESTAMP` reads as the instant it names, and `sql_mode` without `NO_BACKSLASH_ESCAPES`, because generated DDL writes literals such as `COMMENT '...'` that have no hex form. An editor session keeps the server's own zone and `sql_mode`, as the mysql client would. + +sqlx's own session statements (`PIPES_AS_CONCAT`, `NO_ENGINE_SUBSTITUTION`, its `SET NAMES`, its time zone) are turned off, its statement cache is 0 so no prepared statement outlives its call, and its statement logging is off. + +## TLS + +- `Disable` connects without TLS. A server with `require_secure_transport` answers with error 3159, reported as `ServerRequiresTls`. +- `Require` encrypts without checking the certificate. A server that does not offer TLS is reported as `ServerRefusedTls`. +- `VerifyFull` is sqlx's `VerifyIdentity`. It trusts the Mozilla roots compiled into sqlx plus the connection's CA file, and **not** the system's CA store ([Trust](../../ARCHITECTURE.md#trust)). A server certificate from a private CA always needs the CA file. +- The name checked is the host sqlx dials, so a server name override works only through an SSH forward, where the dial goes to a socket path (`ServerNameOverride::SshForwardOnly`). +- A client certificate and key are sent when set, for `REQUIRE X509` logins. +- With TLS off, `caching_sha2_password` sends the password encrypted with the server's RSA key, never in clear text, also over an SSH forward's socket. + +## Stops and timeouts + +A stop is `KILL QUERY ` on the control connection, which reaches the server over the same route as the connection it stops. The id comes from `CONNECTION_ID()` at login. It names the connection, not the statement: `StopDelivery::OutOfBand(StopScope::Connection)`. A pool connection goes back to the pool only when the server confirmed the stop (the statement ended with 1317, or with the server's own timeout after the `KILL` landed) and the `KILL` itself finished; a call stopped between statements also gives up its connection, since a `KILL` may be on its way to it. Error 1094 (no such thread) counts as delivered: there was nothing left to stop. + +| Server answer | Reported as | +|---|---| +| 1317 | `Cancelled`, or `Timeout { server_cancelled: true }` when the deadline asked for it | +| 3024 (MySQL), 1969 (MariaDB) | `Timeout { phase: Statement, server_cancelled: true }` | +| 1205 | `Timeout { phase: LockWait }` | +| 1792 | `ReadOnly(ServerRejected)` | +| 1045 | `Auth` | +| 1040 | `Busy` | +| 3159 | `Tls { failure: ServerRequiresTls }` | +| 1053, 1927 (MariaDB), 4031 (MySQL) | `ConnectionLost` | + +A call that timed out or lost its connection is checked before the link is declared lost: a `KILL` that never got through means the route is gone, one the server answered means it is not, and otherwise a login of its own answers within the cancel grace. + +### Through ProxySQL + +`KILL QUERY` stops the server thread `CONNECTION_ID()` named at login. Behind a proxy that multiplexes client connections over server connections, as ProxySQL does by default, the client may not be on that thread any more, and the `KILL` can miss or stop another client's statement. The driver has no proxy detection. What it does guarantee: a stop counts as confirmed only when the statement itself reported it, and a connection whose stop was not confirmed within the cancel grace is closed, so the app never claims a cancel that did not happen. Point TablePro at a ProxySQL user or query rule with multiplexing off, or at the server directly. + +## Read-only + +`ReadOnlyEnforcement::SessionDefault`: `SET SESSION TRANSACTION READ ONLY` on every session. Saves are refused before anything is sent, a write through generated SQL fails with 1792, and an export runs in `START TRANSACTION READ ONLY` and refuses SQL that does not start with `SELECT`, `WITH`, `SHOW`, `DESCRIBE`, `DESC`, `EXPLAIN` or `TABLE`. In the editor, the user's own `SET TRANSACTION READ WRITE` or `SET SESSION TRANSACTION READ WRITE` lifts it. For a guarantee, connect with a login that has no write privileges. + +## Writes + +Whether a write can be undone depends on the table's storage engine, not on the server. Before a row change the driver reads the table's engine and whether that engine supports transactions (`information_schema.TABLES` joined to `ENGINES`): + +| Change | Engine | How it runs | +|---|---|---| +| Row changes | Transactional (InnoDB) | One transaction. A failure rolls back. | +| Row changes | Not transactional (MyISAM, MEMORY, ARCHIVE, CSV, MariaDB's Aria), or a view | Every probe first, then statement by statement. Each statement lands on its own, and the `WriteLedger` reports how far it got. | +| Schema changes | Any | Statement by statement: each DDL statement commits on its own. | + +- An exact row guard relies on `CLIENT_FOUND_ROWS`, which sqlx turns on: an `UPDATE` reports the rows it matched, also the ones it left unchanged. +- On a non-transactional table, a statement whose connection drops before its answer may have run, so it is reported as unknown, not as refused. +- A trigger on an InnoDB table can write into a non-transactional one. A `ROLLBACK` then leaves those rows, and the server says so with warning 1196. The driver reads `SHOW WARNINGS` after every rollback, and a 1196 makes the outcome `PartiallyApplied` instead of `RolledBack`. +- MariaDB's `max_statement_time` would stop a long `ALTER` the way it stops a `SELECT`, so a schema change on MariaDB lifts it for the batch and puts it back after. A connection where it cannot be put back is closed. MySQL's bound covers only `SELECT`. +- A statement with parameters is prepared and closed after it runs; one without goes over the text protocol, which accepts statements MySQL cannot prepare, such as `CREATE TRIGGER`. +- MySQL cannot suspend foreign keys for one batch, so a batch that asks for it is refused before anything is sent. +- There is nothing to ask the server about a lost `COMMIT` (`CommitResolutionSupport::Unavailable`). `CommitOutcomeUnknown` carries no token, and the user reloads to see what landed. + +## Editor sessions + +- The editor sends one statement per run. Text with several statements runs as the server's own multi-statement batch over the text protocol, except that mysql-client `DELIMITER` lines split it where the client would, because the server does not know the directive. +- The row limit is `SET SESSION sql_select_limit = `, sent when the limit changes. The server stops a plain `SELECT` there. A `SELECT` with a `LIMIT` of its own, or one inside a routine, is read to its end, and the rows past the limit are counted, never decoded. +- A stop sends `KILL QUERY` on the session's own control connection and gives the statement the cancel grace to end. A statement that has not ended by then ends the session, because its answer is still on the way. +- When the grid stops reading, the run ends as cancelled with no `KILL`, and sqlx reads the rest of the result off the wire before the next statement. +- The transaction state after each run comes from `@@in_transaction` on MariaDB and from `performance_schema.events_transactions_current` on MySQL. On MySQL before 8.0.16, with `performance_schema` off, or for a login that cannot read it, the state is `Unknown`, and the tab's close guard warns instead of guessing. diff --git a/linux/docs/drivers/postgresql.md b/linux/docs/drivers/postgresql.md new file mode 100644 index 0000000000..fdd14f6118 --- /dev/null +++ b/linux/docs/drivers/postgresql.md @@ -0,0 +1,144 @@ +# PostgreSQL + +Driver id `postgres`, crate `crates/drivers/postgres`. It runs on tokio-postgres 0.7.18 with postgres-types and postgres-protocol, deadpool-postgres 0.14.2 for the pool and tokio-postgres-rustls 0.14 for TLS, all from crates.io ([ADR 0016](../decisions/0016-driver-contract-v2.md)). The integration suite runs against PostgreSQL 17, with the catalogue also checked on 13 and 18. The contract it implements is in [adding-drivers.md](../adding-drivers.md). + +## Connections + +| Connection | Used for | Opened | +|---|---|---| +| Workspace pool, 4 connections (`WORKSPACE_POOL_MAX_SIZE`) | The sidebar, the grid, counts, saves | The first at connect, the rest on demand | +| One connection per call | `stream_read` (exports), and `probe` when every pool slot is busy | For that call, closed after it | +| One connection per editor tab | `EditorSession` | When the tab first runs SQL | +| One stream per stop | The cancel request | For that request | + +Every connection logs in with `Config::connect_raw` over a stream from the transport, so a direct route and an SSH forward behave the same. The login sends only the user, the password, the database, `application_name = TablePro` and `channel_binding = prefer`, under the 15 s login timeout. Startup options are never used: a transaction pooler refuses parameters it does not track. + +## TLS + +- `Disable` sends no SSLRequest. `Require` and `VerifyFull` both send it and refuse to continue in plaintext; they differ only in the certificate check. +- `VerifyFull` trusts the system store plus the connection's CA file ([Trust](../../ARCHITECTURE.md#trust)), and checks the name from `NetworkTarget::tls_server_name()`. +- A client certificate is offered when one is set, for `cert` authentication in `pg_hba.conf`. +- A server that answers the SSLRequest with `N` is reported as `ServerRefusedTls`, never as a certificate problem: the TLS connector records whether the handshake started. +- SCRAM channel binding (`tls-server-end-point`) is preferred, never required. It is used when the server's certificate is signed with RSA (SHA-1 to SHA-512) or ECDSA with SHA-256 or SHA-384. With any other signature, such as Ed25519 or ECDSA with SHA-512, tokio-postgres-rustls has no hash to bind with, and the login goes ahead without binding. + +## Workspace calls + +Each read is one pipelined flight on a pool connection: the transaction, the work and the COMMIT go out together and the answers come back in one round trip. + +```sql +START TRANSACTION READ ONLY; SET LOCAL statement_timeout = ; SET LOCAL lock_timeout = 10000 +-- the call's statement +COMMIT +``` + +- Reads always run in a `READ ONLY` transaction, also on a read-write connection. +- `statement_timeout` is the nearer of the call's deadline and the workspace's 60 s. `lock_timeout` is 10 s, so a blocked edit reports the lock instead of a generic timeout. +- `SET LOCAL` ends with the transaction, so nothing is left on a server connection a pooler shares. +- Generated SQL runs on the unnamed statement (`query_typed_raw`, `execute_typed`) with typed parameters. Nothing is prepared under a name. +- A connection whose flight did not end with an acknowledged COMMIT or ROLLBACK is taken out of the pool, because deadpool's recycle check (`RecyclingMethod::Verified`) passes inside a half-finished transaction. +- A read that returns no rows still has its columns: the statement runs once more as a subquery right-joined to one row on a false condition, in a read-only transaction of its own. +- A column of a type tokio-postgres does not know (an enum, `citext`, a domain) would make it prepare a type lookup under a name. Pages project such columns as text, so a read never does. + +`probe` takes a pool slot without waiting; when every slot is busy it opens a connection of its own and runs `SELECT 1`. `stream_read` opens a connection of its own, runs `START TRANSACTION READ ONLY` with no statement timeout, and refuses user SQL that would end that transaction (transaction control, `CALL`, `DO`) before sending anything. + +## Stops and timeouts + +A stop is PostgreSQL's cancel request (`CancelToken::cancel_query_raw`) over a new stream from the transport, so it goes through the SSH forward too. It names the backend, not the statement: `StopDelivery::OutOfBand(StopScope::Connection)`. The connection it chases goes back to the pool only when the server answered with SQLSTATE 57014 and the cancel request itself finished; otherwise the connection is closed, because a late cancel request would stop the next caller's statement. + +| Server answer | Reported as | +|---|---| +| 57014 after our cancel request | `Cancelled`, or `Timeout { server_cancelled: true }` when the deadline asked for it | +| 57014 with "statement timeout" | `Timeout { phase: Statement, server_cancelled: true }` | +| 55P03 | `Timeout { phase: LockWait }` | +| 25006 | `ReadOnly(ServerRejected)` | +| 28P01, 28000 | `Auth` | +| 53300 | `Busy` | +| 57P01, 57P02, 57P03 | `ConnectionLost` | + +A call that timed out or lost its connection is checked before the link is declared lost: a cancel request that never got through means the route is gone, one the server answered means it is not, and otherwise a connection of its own answers within the cancel grace. + +## Read-only + +`ReadOnlyEnforcement::SessionDefault`. Read-only is a property of each transaction the driver opens, never a session setting: + +- Workspace reads run in `READ ONLY` transactions, and saves are refused before anything is sent (`ReadOnly(ClientGuard)`). +- In the editor, a statement run outside a transaction is wrapped in `START TRANSACTION READ ONLY` and `COMMIT`. After the user's own `BEGIN`, the driver sends `SET TRANSACTION READ ONLY`. +- A statement that cannot run inside a transaction block, such as `VACUUM`, fails inside the wrapper with 25001 and is reported as refused by read-only mode. `PREPARE TRANSACTION`, `COMMIT PREPARED` and `ROLLBACK PREPARED` are refused before sending. +- `SET default_transaction_read_only = off` does not escape, because each transaction is opened read-only explicitly. +- A user's own `SET TRANSACTION READ WRITE` inside their transaction can still lift it. For a guarantee, connect with a role that cannot write. + +## Writes + +`WriteAtomicity::Transactional` for row and schema changes. A save is one transaction: + +1. `START TRANSACTION READ WRITE` with `SET LOCAL lock_timeout` of 10 s and `SET LOCAL statement_timeout` of 60 s for row changes, 0 (none) for schema changes. A schema change stays bounded by the call's deadline and cancel. +2. Each step: its probe, if any; then the statement with `execute_typed`; then the affected-row check. +3. The transaction id, `pg_current_xact_id()` (or `txid_current()` before PostgreSQL 13), read before COMMIT. +4. `COMMIT`. + +A COMMIT the server answers with an error (a deferred constraint, a serialization failure) rolled back. A COMMIT that gets no answer is `CommitOutcomeUnknown` with `CommitToken::PostgresXid`, and `resolve_commit` asks `pg_xact_status` (or `txid_status`): + +| Status | `CommitResolution` | +|---|---| +| `committed` | `Applied` | +| `aborted` | `NotApplied` | +| `in progress` | `StillRunning` | +| NULL, the record aged out of the commit log | `Unresolvable` | + +PostgreSQL cannot suspend foreign keys, so a batch that asks for it is refused before anything is sent. + +### Row identity + +The grid names a row by, in order: the primary key; a valid unique index with no predicate and no expressions over `NOT NULL` columns; or, for a table or a materialised view with neither, the row's physical address. The address is `ctid` with `xmin`, plus `tableoid` for a partitioned table, where a `ctid` is unique only within one partition. `xmin` is bound as `$n::xid`: after `VACUUM` a freed `ctid` can hold a new row, and `xmin` makes a save against the old row match nothing instead of the new one. Anything else is read-only in the grid. + +## Editor sessions + +An editor tab runs SQL the way psql does: + +- The script is split on the client with the PostgreSQL grammar and sent one statement per message, stopping at the first failure. When a statement changes `standard_conforming_strings`, the rest of the script is split again with the new rule. +- No session-level `SET` is ever sent. The run's deadline and the session's statement timeout are enforced with cancel requests, and nothing new goes to the server until an outstanding cancel request has finished. A statement that has not ended by the end of the cancel grace ends the session. +- Notices (`RAISE NOTICE`, warnings) arrive as `ResultEvent::Notice`. +- `COPY FROM STDIN` and `COPY TO STDOUT` are refused with `Unsupported`. +- Result columns are text: the simple protocol gives a column no type, so every value arrives as `Value::Text`. Typed columns wait on rust-postgres #1358. + +### The row limit + +A statement that returns rows (`SELECT`, `VALUES`, `TABLE`, `WITH`, or one that opens with a parenthesis) is read through a cursor, so a result larger than the limit stops on the server: + +```sql +BEGIN -- or START TRANSACTION READ ONLY, or SAVEPOINT tablepro_cursor_savepoint in the user's transaction +DECLARE tablepro_cursor NO SCROLL CURSOR FOR + +FETCH FORWARD FROM tablepro_cursor +CLOSE tablepro_cursor +COMMIT -- or RELEASE SAVEPOINT tablepro_cursor_savepoint +``` + +- One row past the limit tells a full result from a cut one. A cut result has `total_rows: None`. +- Each step goes as its own message, so a trailing `--` comment in the statement cannot swallow the `FETCH`. +- A `DECLARE` the server refuses because the statement cannot be a cursor (a data-modifying `WITH`: 0A000; `SELECT INTO`: 42601) is rolled back to the savepoint or out of the driver's transaction, and the statement runs again as a plain statement. Inside the user's transaction any other failure aborts their transaction, as it would without the limit. +- Error positions are corrected for the `DECLARE` prefix, so they count from the start of the user's statement. +- A cursor plans for its first rows (`cursor_tuple_fraction`, 0.1 by default) and never uses a parallel plan, so a statement can run with a different plan than in psql. + +### Transaction state + +tokio-postgres drops the transaction status the server sends with every ReadyForQuery, so the driver follows it from what each statement was and whether it succeeded, under PostgreSQL's own rules. Where the rules leave it open (`CALL` and `DO`, which can commit inside, `COMMIT AND CHAIN`, a failed `COMMIT`, and after any stop) it asks the server: + +```sql +SAVEPOINT tablepro_probe -- succeeds inside a transaction, then RELEASE SAVEPOINT tablepro_probe + -- 25P01 outside a transaction, 25P02 in an aborted one +``` + +Outside a transaction this probe fails by design, and the server logs it as an ERROR line (25P01). `RunSummary::transaction_state` is never `Unknown` on PostgreSQL. + +## Connection poolers + +The driver works behind PgBouncer in transaction pooling with `max_prepared_statements = 0`: it prepares nothing under a name and sets nothing on the session. The integration suite runs against `edoburu/pgbouncer` in all three modes (`tests/integration/pgbouncer.rs`). + +| PgBouncer `pool_mode` | Workspace | Editor | +|---|---|---| +| `session` | Works | Works, including the user's own `SET`, temporary tables and prepared statements across runs | +| `transaction` | Works | Each statement and each transaction works. A `SET`, a temporary table or a `PREPARE` lasts only until the user's transaction ends: the next statement may reach another server connection, and a session-level `SET` stays behind for whoever gets that one next. Use session pooling for those. | +| `statement` | Unsupported: reads and saves run in transaction blocks, which the pooler refuses, and a save fails with the pooler's message | Unsupported: the cursor flight is a transaction block | + +Cancel requests go to the pooler like any other connection, and PgBouncer forwards them to the server connection the client is using. diff --git a/linux/docs/drivers/sql-server.md b/linux/docs/drivers/sql-server.md new file mode 100644 index 0000000000..f3a03f9a53 --- /dev/null +++ b/linux/docs/drivers/sql-server.md @@ -0,0 +1,103 @@ +# SQL Server + +Driver id `mssql`, crate `crates/drivers/mssql`. It runs on tiberius 0.13.0 from crates.io (`tds73`, `rustls`, `chrono`, and `integrated-auth-gssapi` behind the crate's `kerberos` feature) with a deadpool 0.13 pool. The integration suite runs against SQL Server 2022. The contract it implements is in [adding-drivers.md](../adding-drivers.md). + +## Connections + +| Connection | Used for | Session | +|---|---|---| +| Workspace pool, 4 connections (`WORKSPACE_POOL_MAX_SIZE`) | The sidebar, the grid, counts, saves | `SET LOCK_TIMEOUT 10000; SET XACT_ABORT ON;` | +| One connection per call | `stream_read` (exports), and `probe` when every pool slot is busy | Workspace, or none for the probe | +| One connection per editor tab | `EditorSession` | The server's defaults: whatever the user sets is theirs | + +- `XACT_ABORT ON` rolls a transaction back on any error, so a failed statement never leaves one open on a pooled connection. `LOCK_TIMEOUT` makes a blocked edit report the lock (error 1222) instead of running into the statement deadline. +- A pool member is handed out again only when `@@TRANCOUNT` is 0. +- tiberius's own 30 s command timeout is off: each call is bounded by its own deadline, and an editor query may run longer. + +Every login gets its stream from the transport, so a direct route and an SSH forward behave the same. A stream must open within the connect timeout and the TDS login finish within the login timeout. + +## Routing + +An Azure SQL gateway or an availability group listener can answer a login with a routing token that names another server. The driver follows it once: it asks the transport for a route to the new host and port (`Transport::reroute`, a new forward on the same SSH master when the connection goes through SSH) and logs in there, checking TLS and building the Kerberos SPN against the new host. A second routing answer is refused, so a gateway that keeps redirecting cannot loop. + +## TLS + +TLS is negotiated inside the TDS pre-login exchange, after the server has said what encryption it supports. + +| `TlsMode` | tiberius setting | Certificate check | +|---|---|---| +| `Disable` | `EncryptionLevel::NotSupported` | None. A server that requires encryption is reported as `ServerRequiresTls`. | +| `Require` | `EncryptionLevel::Required` with `trust_cert` | None | +| `VerifyFull` | `EncryptionLevel::Required` | The system's CA store, loaded by tiberius, plus the connection's CA file (`trust_cert_ca`). The name is the host, or the override (`hostname_in_certificate`). | + +- A server that will not encrypt when asked to is reported as `ServerRefusedTls`. +- No client certificate: SQL Server logins are not authenticated by one, so a TLS setting that carries one is refused. +- tiberius takes no rustls `ClientConfig` and keeps only the text of a rustls error, so the driver classifies TLS failures from that text (`UnknownIssuer`, `NameMismatch`, `Expired` and so on). This is coarser than the other engines, which read the rustls error itself, and a phrase rustls changes in a later release falls back to `TlsFailure::Other`. +- The CA file is checked before the login, because tiberius reads it during the handshake and would report a bad file as an I/O error. + +## Authentication + +- **Password**: a SQL Server login. +- **Windows (Kerberos)**: only in a build with the `kerberos` feature, which links MIT Kerberos. The driver sends no username or password and uses the ticket the user already holds from `kinit`, for the SPN `MSSQLSvc/:` built from the host and port the user typed, never from a local SSH forward. The Kerberos calls block, so the login runs on Tokio's blocking pool and the login timeout ends it. The README's [SQL Server section](../../README.md#sql-server-with-windows-integrated-auth) has the realm and SPN rules. + +### Kerberos inside Flatpak + +The sandbox has no `/etc/krb5.conf` of the host, and its `/tmp` is private, so a FILE ticket cache from `kinit` on the host is invisible: + +- A build with `-Dflatpak=true` and Kerberos installs `build-aux/flatpak/krb5.conf` into the app's `sysconfdir`. It uses the KCM credential cache and finds the realm and the KDC through DNS. +- The development manifest grants the KCM socket, `/run/.heim_org.h5l.kcm-socket`, so a host that runs `sssd-kcm` shares its tickets with the app. +- A FILE cache works only when `KRB5CCNAME` points at a file the sandbox can read, such as one under the home folder. + +Running from source remains the supported path for Kerberos today. + +## Read-only + +`ReadOnlyEnforcement::LoginPrivileges`. SQL Server has no read-only session or transaction mode, so the login's permissions are the only guarantee, and the connect dialog says so. Use a login granted only `SELECT` for a read-only connection. The driver adds guards against accidents, not against intent: + +- Saves are refused before anything is sent (`ReadOnly(ClientGuard)`). +- A generated read on a read-only connection runs inside `BEGIN TRANSACTION` and `ROLLBACK TRANSACTION`, so a filter fragment that writes is undone. A fragment that ends the guard's transaction itself (errors 266, 3902, 3903) is reported as refused by read-only mode, never as a success. +- An export, on any connection, refuses user SQL that is not one batch of statements starting with `SELECT`, `WITH` or a parenthesis, or that contains a word that writes or runs code (`INSERT`, `EXEC`, `MERGE`, `INTO` and the like), and runs inside the same rolled-back transaction. +- An editor session has no guard: it runs whatever the login may run. + +## Stops and timeouts + +tiberius borrows its client for the whole of a request, so nothing outside the task that owns the client can stop one. The stop is a TDS attention, sent by that task on the request's own connection (`Client::cancel_query`): `StopDelivery::InBand`. When the stop fires, the task: + +1. gives the request a quarter of the cancel grace to reach a point between TDS tokens, because a request half read finishes cleanly if given a moment; +2. sends the attention and waits up to half of the grace for the server's acknowledgement; +3. on a workspace connection, rolls back any transaction the request left open (`IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION`), because an attention does not end one. An editor session keeps the user's transaction. + +The connection is reused only when the attention was acknowledged at a token boundary. One stopped in the middle of a token, or whose attention got no acknowledgement or something else back, is closed. During a save the attention is never sent while `COMMIT TRANSACTION` is in flight. + +tiberius 0.13 stops reading at the first end of message, so an attention sent while SQL Server is still waiting on the request, as in a `WAITFOR`, is never acknowledged, and the connection is closed. A pooled connection is replaced; an editor session ends and the tab opens a new one on its next run. The fix is tiberius-rs/tiberius#459. Until a release has it, `.config/nextest.toml` holds the three tests that need it out of the default run: + +- `cancellation::editor_waitfor_cancel_then_select1_same_client` +- `cancellation::pooled_query_bound_waitfor_deadline_server_cancelled_client_reused` +- `conformance_engine::suite::editor_cancel_reports_cancelled_and_session_stays_ready` + +SQL Server has no session statement timeout. A workspace call's deadline is the nearer of the caller's and the workspace's 60 s, enforced by the attention; a schema change keeps only the caller's. + +| Server answer | Reported as | +|---|---| +| Attention acknowledged | `Cancelled`, or `Timeout { server_cancelled: true }` when the deadline asked for it | +| 1222 | `Timeout { phase: LockWait }` | +| 3906 | `ReadOnly(ServerRejected)` | +| 18456, 18452, 18486, 18487, 18488 | `Auth` | +| 17809 | `Busy` | +| A GSSAPI failure | `IntegratedAuth` | + +## Writes + +`WriteAtomicity::Transactional` for row and schema changes. A save is `BEGIN TRANSACTION`, each step, then `COMMIT TRANSACTION`: + +- A statement with parameters runs through tiberius's `execute`; one without runs through `sp_executesql` with the statement alone, because SQL Server refuses `CREATE VIEW` or `CREATE TRIGGER` next to an empty parameter list. +- The affected-row count includes rows a trigger touched, so a save proves it hit the right row with a probe before the statement rather than with that count. +- A `COMMIT` the server answers with an error rolled back; one whose connection dropped is `CommitOutcomeUnknown` with no token. There is nothing to ask the server afterwards (`CommitResolutionSupport::Unavailable`). +- SQL Server cannot suspend foreign keys for one batch, so a batch that asks for it is refused before anything is sent. + +## Editor sessions + +- A script runs one `GO` batch at a time, as sqlcmd and SSMS do, including `GO `. Each batch goes as a SQL batch, not through `sp_executesql`, so a `USE` or a `SET` in it lasts for the rest of the session. A failed batch does not stop the next one. Text the splitter cannot finish goes to the server whole, so the error is the server's own. +- Rows past the row limit are counted and dropped, never decoded. +- When the grid stops reading, the driver sends an attention instead of reading millions of unwanted rows. +- The transaction state comes from `@@TRANCOUNT` and `XACT_STATE()`, so a transaction an error has doomed reads as `Failed`. diff --git a/linux/docs/drivers/sqlite.md b/linux/docs/drivers/sqlite.md new file mode 100644 index 0000000000..c7417cb847 --- /dev/null +++ b/linux/docs/drivers/sqlite.md @@ -0,0 +1,76 @@ +# SQLite + +Driver id `sqlite`, crate `crates/drivers/sqlite`. It runs on sqlx 0.9 with `sqlite-unbundled`, so it links the system's libsqlite3 rather than a second copy, and calls `libsqlite3-sys` directly once, for `sqlite3_get_autocommit`, until sqlx exposes a connection's transaction state. The library must be SQLite 3.37.0 or newer (`SQLITE_MIN_VERSION`), which the driver checks when it opens the file. The contract it implements is in [adding-drivers.md](../adding-drivers.md). + +There is no network, login or TLS: the target is a `FileEndpoint`, an absolute path with a `FileOpenMode`. + +## Opening a file + +Every connection to the file opens with the same options: + +- The path is a file name, never a URI, so `?` and `%41` in it are part of the name. +- A missing file is `FileNotFound`; the driver never creates one by accident (`create_if_missing` is off). +- A read-only connection opens the file with `SQLITE_OPEN_READONLY`. +- `busy_timeout` is the workspace's lock timeout, 10 s: how long SQLite waits for another connection's lock before it reports `Busy`. +- `foreign_keys = ON`, and `trusted_schema = OFF`, so a view or trigger in a file from elsewhere cannot run SQL functions with side effects. +- The statement cache is off, because sqlx keeps a statement's columns from its first prepare and SQLite prepares it again with more columns after an `ALTER TABLE`. +- sqlx's statement logging is off. + +The connect reads `sqlite_version()` from the file once, so a file that is not a database fails the connect (`NotADatabase`) rather than the first page. SQLite only says it could not open a file; the driver tells a moved file (`FileNotFound`) from one the user may not read (`FileAccessDenied`) by looking at the path. + +### New databases + +`FileOpenMode::CreateNew` is what the New Database flow sends after the save dialog has asked about replacing a file. The driver writes an empty file next to the target with mode 0600, syncs it, renames it over the path, removes any `-wal`, `-shm` or `-journal` file left beside the old database (it would be replayed into the new one), and syncs the folder so the rename is durable. A reader sees the old file or the new one, never half of one. An existing file is replaced, not refused. + +A reconnect uses `FileEndpoint::for_reopen`, which is always `OpenExisting`, so reconnecting never empties a database the user just created. + +### Where the file lives + +SQLite relies on POSIX file locks and, for a database in WAL mode, on shared memory in the `-shm` file beside it. Both work on a local file system. On NFS or SMB, locks can fail without an error, and two writers can then corrupt the file. A FUSE mount may not implement the locks at all. A file chosen through the Flatpak document portal sits on the portal's own FUSE file system, which exposes the chosen file rather than its folder, so the journal and WAL files SQLite keeps beside it may not work there. + +The driver does not detect any of these. Open such a file read-only, or copy it to a local folder first. Under the development Flatpak, which grants `--filesystem=home`, a file under the home folder is opened at its real path, not through the portal. + +## Connections + +| Connection | Used for | +|---|---| +| One workspace connection (`WORKSPACE_POOL_MAX_SIZE` is 1) | The sidebar, the grid, counts, saves | +| One read-only connection per call | `stream_read` (exports) | +| One connection per editor tab | `EditorSession` | + +One workspace connection is enough because SQLite takes one writer at a time anyway, and it keeps a temporary table or an attached database where the next call can see it. Calls wait for it for up to the pool wait (5 s). + +## Stops and timeouts + +SQLite has no statement timeout and no out-of-band cancel. Each call installs a fresh flag, and a progress handler checks it every 1,000 virtual-machine instructions; when the flag is up the statement ends with `SQLITE_INTERRUPT`. The flag names the call, not the connection, so a stop that arrives late cannot reach the next call: `StopDelivery::OutOfBand(StopScope::Statement)`. `sqlite3_interrupt` is not used, because sqlx's handle to the connection waits for the worker thread to finish its current command, which is the statement the stop was meant for. + +The workspace statement timeout (60 s) becomes a deadline the flag enforces. The flag is lowered before a rollback runs, so the rollback is never interrupted by the stop that caused it. + +## Read-only + +`ReadOnlyEnforcement::EngineEnforced`: the file is opened read-only, so SQLite refuses every write, whatever the SQL (`SQLITE_READONLY`, reported as `ReadOnly(ServerRejected)`). Saves on a read-only connection are refused before anything is sent. Exports always open their connection read-only. + +## Writes + +`WriteAtomicity::Transactional` for row and schema changes. A save is one transaction that starts with `BEGIN IMMEDIATE`, which takes the write lock before the first probe reads anything: contention shows up once, as `Busy` after the lock timeout, before anything has run. + +SQLite is the one engine that can suspend foreign keys for a batch (`ForeignKeyMode::SuspendedThenVerified`), which a table rebuild needs for its copy: + +1. `PRAGMA foreign_keys = OFF` before `BEGIN`, since the pragma does nothing inside a transaction. +2. `PRAGMA foreign_key_check` on the named tables before the batch and after it. +3. Only violations the batch introduced fail it, and the batch rolls back. A file can hold violations from long before, written while foreign keys were off, and those do not count. +4. `PRAGMA foreign_keys = ON` after the `COMMIT` or `ROLLBACK`. + +A `COMMIT` never crosses a network, so nothing is left to resolve afterwards (`CommitResolutionSupport::Unavailable`). + +### Row identity + +The grid names a row by its primary key, then by a unique index whose columns are all `NOT NULL`, then by `rowid`. A `WITHOUT ROWID` table with neither key, or a table with a column named `rowid` that hides the real one, is read-only in the grid. + +## Editor sessions + +- A script is split where SQLite itself would end a statement, so the semicolons inside a trigger body do not end it. Text the splitter cannot finish, such as an unterminated string, goes to SQLite whole, so the error is SQLite's own. The splitter is tested against `sqlite3_complete` (`tests/script_plan_matches_sqlite3_complete.rs`). +- Each statement is prepared first, so a `SELECT` that finds nothing still has its columns. A column with no declared type gets its type from the first row, sent as `ResultEvent::ColumnsResolved`. +- The row limit stops the read one row past the limit, and the set ends with `Truncation::ReadPastLimit`. SQLite computes a row only when it is stepped, so the rest of the result is never produced. +- A stop raises the run's flag and gives the statement the cancel grace to notice it; past that its read is dropped. +- The transaction state after a run comes from `sqlite3_get_autocommit`. From 5ca1adbdbb36d235c45bd8865c88b75f50f78f72 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:15:44 +0700 Subject: [PATCH 005/178] docs(linux): record driver contract v2 in ADR 0016 --- .../docs/decisions/0016-driver-contract-v2.md | 57 +++++++++++++++++++ linux/docs/decisions/README.md | 5 +- 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 linux/docs/decisions/0016-driver-contract-v2.md diff --git a/linux/docs/decisions/0016-driver-contract-v2.md b/linux/docs/decisions/0016-driver-contract-v2.md new file mode 100644 index 0000000000..4934f167fb --- /dev/null +++ b/linux/docs/decisions/0016-driver-contract-v2.md @@ -0,0 +1,57 @@ +# 0016: Driver contract v2 + +- **Status**: Accepted +- **Date**: 2026-10-13 + +## Context + +Until the switch to this contract, drivers implemented the first `Connection` trait: `list_tables`, `fetch_rows`, `query`, `execute` and `execute_in_transaction`, with no deadline or cancel on any call. A cancel dropped the caller's future and left the statement running on the server, holding its locks. A save was one `execute_in_transaction` call that returned row counts or an error, never how far it had got. PostgreSQL ran on sqlx, which cannot send a cancel request, caches statements under names a transaction pooler hands to other clients, and dials its own sockets. The 2026-09-14 audit traced lost edits, runaway statements and pooler breakage to these gaps (F-001, F-008, F-012, F-013, F-041, F-047, F-058, F-109). + +## Decision + +Every driver implements the v2 contract in `tablepro-core`: a `DatabaseDriver` that states its `DriverCapabilities`, a `Connection` whose calls run under `OwnedCalls` with the engine's own stop, writes that record their progress in a `WriteLedger`, one `EditorSession` per editor tab, and a `RowLimit` the engine enforces where it can, each built on the engine's maintained client from crates.io with no fork. + +## Rationale + +**Owned calls.** Dropping a future does not stop a server. Each call runs on a task the connection owns, which carries a `StopRequest` into the work, sends the engine's own stop (`EngineStop`) and decides afterwards whether the connection is clean enough to reuse (`CallResource`). A stop that names a connection rather than a statement, as PostgreSQL's cancel request and MySQL's `KILL QUERY` do, can land late on whatever runs next, so that connection goes back to the pool only once the stop has settled and the server has acknowledged it. tiberius borrows its client for the whole request, so SQL Server's attention is sent in band by the task that owns the client. + +**Phase-aware write outcomes.** After a failed save the only question is what is in the database. The work records each step in a `WriteLedger` (statement sent, statement confirmed, commit sent), so an interrupted write becomes `RolledBack`, `PartiallyApplied` or `CommitOutcomeUnknown` from what was actually sent, not from a guess. The engine's stop is held while a commit, or a statement the engine cannot take back, is in flight. Where the server can answer later, the ledger keeps a `CommitToken` for `resolve_commit`: PostgreSQL's transaction id, ClickHouse's query ids. + +**Row limit.** `RowLimit` cannot be zero, and the editor keeps 10,000 rows by default. The limit stops the read on the server where the engine allows it without changing the statement: a cursor fetch on PostgreSQL, `sql_select_limit` on MySQL, `KILL QUERY` on ClickHouse once the limit is passed. SQL Server counts the rows past the limit without keeping them, and SQLite stops stepping the statement. The limit never ends the script or the user's transaction. + +**PostgreSQL on tokio-postgres.** sqlx-postgres keeps the backend's process id and secret key private, so it has no cancel request, and `pg_cancel_backend` from another session reaches the wrong backend behind PgBouncer. It also dials its own sockets and builds its own TLS configuration. tokio-postgres 0.7.18 logs in over any stream (`Config::connect_raw`), sends a cancel request over any stream (`CancelToken::cancel_query_raw`), supports SCRAM channel binding through tokio-postgres-rustls 0.14, and pipelines a transaction, its statement and its COMMIT into one round trip. deadpool-postgres 0.14.2 manages the pool. + +**PgBouncer safety.** Nothing is prepared under a name: generated SQL runs on the unnamed statement (`query_typed_raw`, `execute_typed`). No session-level setting is sent: workspace bounds are `SET LOCAL` inside each call's transaction, and editor sessions apply read-only per transaction and enforce deadlines with cancel requests. The driver therefore works behind transaction pooling with `max_prepared_statements = 0`. Read-only mode and timeouts as startup parameters were rejected because a default PgBouncer refuses startup parameters it does not track. + +**No forks.** The plan of 2026-09-14 carried forks of rust-postgres and tiberius. Both were dropped: tokio-postgres, postgres-types and postgres-protocol come from crates.io, and tiberius 0.13.0 is published with `Client::cancel_query`, `trust_cert_ca` and `hostname_in_certificate`. What upstream lacks is handled in the driver until the upstream change lands: + +| Upstream gap | Until it closes | +|---|---| +| tokio-postgres drops the transaction status of ReadyForQuery | The editor follows the status statement by statement, with a `SAVEPOINT` probe where the rules leave it open | +| tokio-postgres gives simple-query columns no type (rust-postgres #1358) | Editor result columns are text | +| tiberius takes no rustls `ClientConfig` | SQL Server TLS failures are classified from rustls's message text | +| tiberius stops reading at the first end of message, so an attention sent while the server is still waiting is never acknowledged (tiberius-rs/tiberius#459) | The connection is dropped after such a stop, and three cancel tests are held in `.config/nextest.toml` | + +The editor's row limit on PostgreSQL is a `DECLARE` and `FETCH` cursor. tokio-postgres offers a row-limited portal only inside a `Transaction` it opens itself, which would wrap every statement in BEGIN and COMMIT and refuse VACUUM. + +**MySQL without a relay.** sqlx dials a TCP endpoint or a Unix socket itself, and an SSH forward is a Unix socket OpenSSH listens on ([ADR 0013](0013-openssh-client-transport.md)), so the MySQL driver dials the transport's route and nothing relays bytes in between. + +**Trust per engine.** The engines' TLS stacks take trust anchors in different forms, so `NetworkTarget` carries the `TlsConfig` and each driver builds its own trust from it. PostgreSQL and ClickHouse share `tablepro_net::tls::build_client_config`; SQL Server and MySQL use what their clients accept. [ARCHITECTURE.md](../../ARCHITECTURE.md#trust) has the table. + +## Consequences + +- A new driver implements the whole contract and runs both conformance suites ([testing.md](../testing.md#conformance)). `suite_flags_match_driver_capabilities` keeps a driver from skipping a case by declaring less. +- PostgreSQL editor results are text until rust-postgres #1358 ships. The `SAVEPOINT` probe outside a transaction writes an ERROR line (25P01) to the server log. A cursor read plans for its first rows and never uses a parallel plan. Statement pooling is unsupported. +- Trust differs by engine until sqlx and tiberius take a rustls `ClientConfig`: MySQL trusts the Mozilla roots compiled into sqlx, not the system store. +- SQL Server TLS failures are classified more coarsely than the other engines', and three cancel tests stay held until tiberius-rs/tiberius#459 ships. +- [ADR 0009](0009-crypto-provider-and-sources.md) allowed `tiberius-rs/tiberius` as a git source until tiberius 0.13 was published. It is on crates.io now, and `allow-git` in `deny.toml` is empty. +- What each engine does with the contract is in `docs/drivers/`: [PostgreSQL](../drivers/postgresql.md), [MySQL and MariaDB](../drivers/mysql.md), [SQLite](../drivers/sqlite.md), [SQL Server](../drivers/sql-server.md), [ClickHouse](../drivers/clickhouse.md). + +## Alternatives considered + +- **Forks of rust-postgres and tiberius**: they closed the gaps above in the client, but each is a second codebase to keep current and patched ([ADR 0005](0005-supply-chain-policy.md)). +- **sqlx-postgres with `pg_cancel_backend`**: reaches the wrong backend behind PgBouncer, and sqlx dials its own sockets. +- **libpq through FFI**: a second TLS stack and a C dependency. +- **A private socket relay for MySQL**: no purpose once an SSH forward is a Unix socket. +- **Cancel by dropping the caller's future**: leaves the statement running on the server. +- **A `LIMIT` added to the user's SQL**: changes what the statement means, and does nothing for a statement that is not a `SELECT`. diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index 2083497c4a..6b2d4bb7d4 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -1,13 +1,13 @@ # Architecture Decision Records -These are short documents recording the **reasoning** behind major technical choices. They are not living documentation — once an ADR is accepted, it stays. If a later ADR supersedes it, link forward; do not edit history. +These are short documents recording the **reasoning** behind major technical choices. They are not living documentation: once an ADR is accepted, it stays. If a later ADR supersedes it, link forward; do not edit history. Every load-bearing decision in the Linux subproject has an ADR. If a contributor asks "why did we pick X", the answer should already be in this folder. If it is not, we missed an ADR. ## Format ```markdown -# 000N — Title +# 000N: Title - **Status**: Accepted | Superseded by 000M | Deprecated - **Date**: YYYY-MM-DD @@ -49,6 +49,7 @@ ADRs are short on purpose. If yours is more than a page, you are probably arguin | [0009](0009-crypto-provider-and-sources.md) | One crypto provider, upstream sources and dependency bans | Accepted | aws-lc-rs only; deny.toml enforces sources, licenses, bans and advisory ignores. | | [0013](0013-openssh-client-transport.md) | SSH through the system OpenSSH client | Accepted | ControlMaster per connection, Unix-socket forwards, prompts through an askpass helper. | | [0015](0015-gnome-50-platform-floor.md) | GNOME 50 platform floor | Accepted | GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88; AdwShortcutsDialog replaces the shortcuts window. | +| [0016](0016-driver-contract-v2.md) | Driver contract v2 | Accepted | Owned calls with engine stops, phase-aware write outcomes, server-side row limits; PostgreSQL on tokio-postgres; no forks. | ## Adding an ADR From 0eb7864f55f5fa48d6d5fe5e6bfe56c97ad84f88 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:27:22 +0700 Subject: [PATCH 006/178] fix(linux-clickhouse): refuse a result line that never ends --- linux/CHANGELOG.md | 1 + .../drivers/clickhouse/src/editor_run.rs | 41 +++++- .../drivers/clickhouse/src/line_reader.rs | 101 +++++++++++-- .../drivers/clickhouse/src/result_reader.rs | 135 +++++++++++++++--- .../clickhouse/tests/integration/sessions.rs | 84 ++++++++++- 5 files changed, 324 insertions(+), 38 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 5aa8e0614c..6cb304c743 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -18,3 +18,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. +- ClickHouse query or export with a FORMAT clause filled memory instead of failing. diff --git a/linux/crates/drivers/clickhouse/src/editor_run.rs b/linux/crates/drivers/clickhouse/src/editor_run.rs index e16223361c..f350279312 100644 --- a/linux/crates/drivers/clickhouse/src/editor_run.rs +++ b/linux/crates/drivers/clickhouse/src/editor_run.rs @@ -90,8 +90,7 @@ impl EditorRun { emitter.limit_reached().await; // The rest of the result is not wanted, so the read // stops on the server before its body is dropped. - let killing = kill_query(&self.control, query_id); - let _ = tokio::time::timeout(self.grace, killing).await; + kill_within(&self.control, query_id, self.grace).await; break stop.fired.map_or(RunEnd::Completed, |_| stop.end(None)); } } @@ -109,6 +108,13 @@ impl EditorRun { phase: TimeoutPhase::Statement, server_cancelled, }) => break RunEnd::TimedOut { server_cancelled }, + Err(error) if unreadable(&error) => { + // The server is still sending what the driver could not + // read, so the query is stopped there before its body + // is dropped. + kill_within(&self.control, query_id, self.grace).await; + break RunEnd::Failed(error); + } Err(error) => break RunEnd::Failed(error), } if emitter.consumer_gone && stop.fired.is_none() { @@ -137,6 +143,22 @@ fn lost(error: &DriverError) -> bool { ) } +/// A body the driver gave up reading, while the server may still be +/// producing it. +fn unreadable(error: &DriverError) -> bool { + matches!(error, DriverError::Protocol(_) | DriverError::Decode { .. }) +} + +/// A KILL that gives up after `grace`: on a route that stopped +/// answering, it would otherwise wait, and hold its connection, for good. +async fn kill_within(control: &clickhouse::Client, query_id: Uuid, grace: Duration) { + match tokio::time::timeout(grace, kill_query(control, query_id)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => tracing::debug!(%error, "the KILL QUERY for an editor run failed"), + Err(_) => tracing::debug!("the KILL QUERY for an editor run got no answer"), + } +} + /// What stops a run, and when the stop was sent. struct Stop { cancel: CancellationToken, @@ -307,6 +329,21 @@ mod tests { use super::*; + #[test] + fn only_a_body_the_driver_could_not_read_is_killed_after_it_fails() { + let refused = DriverError::reported(ServerDiagnostics::new( + Some(ServerCode::ClickHouse { code: 62 }), + "Code: 62. DB::Exception: Syntax error", + )); + + assert!(unreadable(&DriverError::Protocol("a row ran past 256 MiB".to_owned()))); + assert!(unreadable(&DriverError::Decode { + column: "c".to_owned(), + detail: "not a number".to_owned(), + })); + assert!(!unreadable(&refused)); + } + fn stopped(interrupt: Interrupt) -> Stop { let mut stop = Stop::new( CancellationToken::new(), diff --git a/linux/crates/drivers/clickhouse/src/line_reader.rs b/linux/crates/drivers/clickhouse/src/line_reader.rs index d027d1467d..b09e6f8402 100644 --- a/linux/crates/drivers/clickhouse/src/line_reader.rs +++ b/linux/crates/drivers/clickhouse/src/line_reader.rs @@ -1,45 +1,118 @@ use clickhouse::query::BytesCursor; use tablepro_core::DriverError; -use tokio::io::AsyncBufReadExt; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt}; use crate::errors::map_io_error; +/// What a response body yields next. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Line { + /// One line, without its terminator. + Complete(Vec), + /// The line ran past the limit without ending. + TooLong, + End, +} + /// A response body read one `\n`-terminated line at a time, so a caller /// can stop at a row limit without reading the rest of the result. -pub(crate) struct LineReader { - cursor: BytesCursor, +pub(crate) struct LineReader { + cursor: R, line: Vec, } -impl LineReader { - pub(crate) fn new(cursor: BytesCursor) -> Self { +impl LineReader { + pub(crate) fn new(cursor: R) -> Self { Self { cursor, line: Vec::new(), } } - /// The next line, without its terminator, or `None` at the end. + /// The next line, which may hold at most `limit` bytes. + /// + /// A body in a format with no line breaks, such as RowBinary, is one + /// endless line, so without the limit it would be buffered until the + /// process ran out of memory. At most one byte past the limit is ever + /// held. /// /// Safe to use in `select!`: `read_until` appends to the buffer, so a /// read cut short keeps what it had and the next call carries on. - pub(crate) async fn next_line(&mut self) -> Result>, DriverError> { - self.cursor + pub(crate) async fn next_line(&mut self, limit: usize) -> Result { + let room = limit.saturating_add(1).saturating_sub(self.line.len()); + (&mut self.cursor) + .take(u64::try_from(room).unwrap_or(u64::MAX)) .read_until(b'\n', &mut self.line) .await .map_err(map_io_error)?; - if self.line.is_empty() { - return Ok(None); - } - let mut line = std::mem::take(&mut self.line); - if line.last() == Some(&b'\n') { + if self.line.last() == Some(&b'\n') { + let mut line = std::mem::take(&mut self.line); line.pop(); + return Ok(Line::Complete(line)); + } + if self.line.len() > limit { + return Ok(Line::TooLong); } - Ok(Some(line)) + if self.line.is_empty() { + return Ok(Line::End); + } + Ok(Line::Complete(std::mem::take(&mut self.line))) } +} +impl LineReader { /// The `X-ClickHouse-Summary` the server sent with the headers. pub(crate) fn written_rows(&self) -> Option { self.cursor.summary().and_then(clickhouse::QuerySummary::written_rows) } } + +#[cfg(test)] +mod tests { + use super::*; + + async fn lines(body: &[u8], limit: usize) -> Vec { + let mut reader = LineReader::new(body); + let mut lines = Vec::new(); + loop { + let line = reader.next_line(limit).await.expect("a line"); + let last = matches!(line, Line::End | Line::TooLong); + lines.push(line); + if last { + return lines; + } + } + } + + #[tokio::test] + async fn lines_split_on_newlines_and_the_last_needs_none() { + assert_eq!( + lines(b"[1]\n[22]\n[3]", 4).await, + vec![ + Line::Complete(b"[1]".to_vec()), + Line::Complete(b"[22]".to_vec()), + Line::Complete(b"[3]".to_vec()), + Line::End, + ] + ); + assert_eq!(lines(b"", 4).await, vec![Line::End]); + } + + #[tokio::test] + async fn a_line_past_the_limit_is_refused_whether_or_not_it_ends() { + assert_eq!( + lines(b"[1]\n12345\n", 4).await, + vec![Line::Complete(b"[1]".to_vec()), Line::TooLong] + ); + assert_eq!(lines(b"12345", 4).await, vec![Line::TooLong]); + } + + #[tokio::test] + async fn an_endless_line_buffers_no_more_than_one_byte_past_the_limit() { + let endless = tokio::io::BufReader::new(tokio::io::repeat(b'x')); + let mut reader = LineReader::new(endless); + + assert_eq!(reader.next_line(1024).await.expect("a line"), Line::TooLong); + assert_eq!(reader.line.len(), 1025); + } +} diff --git a/linux/crates/drivers/clickhouse/src/result_reader.rs b/linux/crates/drivers/clickhouse/src/result_reader.rs index bcc03a232b..49d9566995 100644 --- a/linux/crates/drivers/clickhouse/src/result_reader.rs +++ b/linux/crates/drivers/clickhouse/src/result_reader.rs @@ -3,11 +3,12 @@ use std::sync::Arc; use clickhouse::query::{BytesCursor, Query}; use tablepro_core::column::ResultColumn; use tablepro_core::{DriverError, Value}; +use tokio::io::AsyncBufRead; use crate::cell_seed::CellPlan; use crate::clickhouse_type::ClickHouseType; use crate::errors::map_error; -use crate::line_reader::LineReader; +use crate::line_reader::{Line, LineReader}; use crate::row_seed::decode_row; /// Rows one JSON array per line, after a line of column names and a line @@ -15,6 +16,24 @@ use crate::row_seed::decode_row; /// types come with the result, so each cell is read by its own type. pub(crate) const RESULT_FORMAT: &str = "JSONCompactEachRowWithNamesAndTypes"; +/// How long a line of the body may grow before the read gives up. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct LineLimits { + /// A header line holds the column names or types, which no real + /// result makes this long. A longer one is a body in another format, + /// which a FORMAT clause in user SQL asks for. + pub(crate) header: usize, + /// One row, which the read has to hold whole to decode. + pub(crate) row: usize, +} + +impl LineLimits { + pub(crate) const DEFAULT: Self = Self { + header: 16 << 20, + row: 256 << 20, + }; +} + /// What a response body yields next. #[derive(Debug)] pub(crate) enum ReadEvent { @@ -25,46 +44,68 @@ pub(crate) enum ReadEvent { /// A response body read as events. The state lives here rather than in /// the future reading it, so it may be raced against a stop. -pub(crate) struct ResultReader { - lines: LineReader, +pub(crate) struct ResultReader { + lines: LineReader, + limits: LineLimits, names: Option>, plans: Option>, } impl ResultReader { - pub(crate) fn new(cursor: BytesCursor) -> Self { - Self { - lines: LineReader::new(cursor), - names: None, - plans: None, - } - } - /// Send the query and read its body as events. pub(crate) fn open(query: Query) -> Result { query .fetch_bytes(RESULT_FORMAT) - .map(Self::new) + .map(|cursor| Self::new(cursor, LineLimits::DEFAULT)) .map_err(|error| map_error(&error)) } + pub(crate) fn written_rows(&self) -> Option { + self.lines.written_rows() + } +} + +impl ResultReader { + pub(crate) fn new(cursor: R, limits: LineLimits) -> Self { + Self { + lines: LineReader::new(cursor), + limits, + names: None, + plans: None, + } + } + /// Whether the body began with a result set. A statement without one, /// such as an INSERT or a CREATE, sends an empty body. pub(crate) fn has_result_set(&self) -> bool { self.plans.is_some() } - pub(crate) fn written_rows(&self) -> Option { - self.lines.written_rows() - } - pub(crate) async fn next_event(&mut self) -> Result { loop { - let Some(line) = self.lines.next_line().await? else { - if self.names.is_some() && self.plans.is_none() { - return Err(DriverError::Protocol("the result ended inside its header".to_owned())); + let limit = match self.plans { + Some(_) => self.limits.row, + None => self.limits.header, + }; + let line = match self.lines.next_line(limit).await? { + Line::Complete(line) => line, + Line::TooLong if self.plans.is_some() => { + return Err(DriverError::Protocol(format!( + "a row ran past {} MiB, the most the driver reads into memory", + self.limits.row >> 20 + ))); + } + Line::TooLong => { + return Err(DriverError::Protocol(format!( + "the result did not come back as {RESULT_FORMAT}; a FORMAT clause in the query changes the format" + ))); + } + Line::End => { + if self.names.is_some() && self.plans.is_none() { + return Err(DriverError::Protocol("the result ended inside its header".to_owned())); + } + return Ok(ReadEvent::End); } - return Ok(ReadEvent::End); }; if let Some(plans) = &self.plans { return decode_row(&line, plans).map(ReadEvent::Row); @@ -205,3 +246,57 @@ impl CatalogRow<'_> { fn unexpected(index: usize, value: Option<&Value>) -> DriverError { DriverError::Protocol(format!("a catalogue column {index} held {value:?}")) } + +#[cfg(test)] +mod tests { + use super::*; + + const SMALL: LineLimits = LineLimits { header: 64, row: 16 }; + + fn reader(body: &[u8]) -> ResultReader<&[u8]> { + ResultReader::new(body, SMALL) + } + + #[tokio::test] + async fn a_body_reads_as_columns_rows_and_an_end() { + let mut reader = reader(b"[\"n\"]\n[\"UInt8\"]\n[1]\n[2]\n"); + + let ReadEvent::Columns(columns) = reader.next_event().await.expect("the header") else { + panic!("the header was not read first"); + }; + assert_eq!(columns.len(), 1); + for expected in [1, 2] { + let ReadEvent::Row(row) = reader.next_event().await.expect("a row") else { + panic!("row {expected} was not read"); + }; + assert_eq!(row, vec![Value::Int(expected)]); + } + assert!(matches!(reader.next_event().await, Ok(ReadEvent::End))); + } + + #[tokio::test] + async fn a_body_in_another_format_is_refused_at_the_header_limit() { + let row_binary: Vec = b"\x01x".repeat(64); + + let refused = reader(&row_binary).next_event().await; + + let Err(DriverError::Protocol(message)) = refused else { + panic!("a RowBinary body gave {refused:?}"); + }; + assert!(message.contains("FORMAT"), "{message}"); + } + + #[tokio::test] + async fn a_row_past_the_row_limit_is_refused_after_the_rows_before_it() { + let body = format!("[\"s\"]\n[\"String\"]\n[\"a\"]\n[\"{}\"]\n", "x".repeat(16)); + let mut reader = reader(body.as_bytes()); + + assert!(matches!(reader.next_event().await, Ok(ReadEvent::Columns(_)))); + assert!(matches!(reader.next_event().await, Ok(ReadEvent::Row(_)))); + let refused = reader.next_event().await; + assert!( + matches!(&refused, Err(DriverError::Protocol(message)) if message.contains("MiB")), + "{refused:?}" + ); + } +} diff --git a/linux/crates/drivers/clickhouse/tests/integration/sessions.rs b/linux/crates/drivers/clickhouse/tests/integration/sessions.rs index 44d5711c46..9859a74625 100644 --- a/linux/crates/drivers/clickhouse/tests/integration/sessions.rs +++ b/linux/crates/drivers/clickhouse/tests/integration/sessions.rs @@ -3,10 +3,44 @@ use std::time::Duration; -use tablepro_core::{ResultEvent, RowLimit, RunEnd, RunOptions, Truncation, Value}; +use async_trait::async_trait; +use tablepro_core::{ + Connection, DriverError, ReadStatement, ResultEvent, ResultSink, RowLimit, RunEnd, RunOptions, SinkFlow, + Truncation, UserSql, Value, +}; +use tokio::time::Instant; use tokio_util::sync::CancellationToken; -use crate::support::{connect, count, flush_logs, run, run_ok, run_with, scalar, session, start, text, unique}; +use crate::support::{ + CALL, connect, count, flush_logs, run, run_ok, run_with, scalar, session, start, text, unique, within, +}; + +/// A result that holds no line break, so one read as lines is one line +/// that never ends. +fn row_binary(marker: &str) -> String { + format!("SELECT '{marker}' FROM numbers(10000000000) FORMAT RowBinary") +} + +async fn wait_until_gone(connection: &dyn Connection, marker: &str) { + let running = format!( + "SELECT count() FROM system.processes WHERE query LIKE '%{marker}%' \ + AND query NOT LIKE '%system.processes%'" + ); + let give_up = Instant::now() + Duration::from_secs(10); + while count(connection, &running).await > 0 { + assert!(Instant::now() < give_up, "the statement is still running"); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +struct Discard; + +#[async_trait] +impl ResultSink for Discard { + async fn accept(&mut self, _event: ResultEvent) -> SinkFlow { + SinkFlow::Continue + } +} #[tokio::test] #[ignore = "requires docker"] @@ -155,3 +189,49 @@ async fn a_question_mark_in_editor_sql_is_data() { editor.close().await; connection.close().await.expect("a close"); } + +#[tokio::test] +#[ignore = "requires docker"] +async fn a_format_clause_fails_the_run_instead_of_filling_memory() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let editor = session(connection.as_ref()).await; + let marker = unique("format"); + + let (summary, _) = tokio::time::timeout(Duration::from_secs(60), run(editor.as_ref(), &row_binary(&marker))) + .await + .expect("the run ended"); + + assert!( + matches!(&summary.end, RunEnd::Failed(DriverError::Protocol(message)) if message.contains("FORMAT")), + "{:?}", + summary.end + ); + wait_until_gone(connection.as_ref(), &marker).await; + run_ok(editor.as_ref(), "SELECT 1").await; + editor.close().await; + connection.close().await.expect("a close"); +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn a_format_clause_fails_an_export_instead_of_filling_memory() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let marker = unique("export"); + let read = ReadStatement::User(UserSql::new(row_binary(&marker))); + + let outcome = tokio::time::timeout( + Duration::from_secs(60), + connection.stream_read(&read, Box::new(Discard), within(CALL)), + ) + .await + .expect("the export ended"); + + assert!( + matches!(&outcome, Err(DriverError::Protocol(message)) if message.contains("FORMAT")), + "{outcome:?}" + ); + wait_until_gone(connection.as_ref(), &marker).await; + connection.close().await.expect("a close"); +} From 3798bd4c7ae8620458c2f8551f2abe8416b5f1f3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:27:27 +0700 Subject: [PATCH 007/178] fix(linux-clickhouse): give up on an editor KILL after the grace --- .../drivers/clickhouse/src/editor_run.rs | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/linux/crates/drivers/clickhouse/src/editor_run.rs b/linux/crates/drivers/clickhouse/src/editor_run.rs index f350279312..dfc0cd3e03 100644 --- a/linux/crates/drivers/clickhouse/src/editor_run.rs +++ b/linux/crates/drivers/clickhouse/src/editor_run.rs @@ -201,11 +201,8 @@ impl Stop { self.fired = Some(interrupt); self.give_up = Some(Instant::now() + self.grace); let control = control.clone(); - tokio::spawn(async move { - if let Err(error) = kill_query(&control, query_id).await { - tracing::debug!(%error, "the KILL QUERY for an editor run failed"); - } - }); + let grace = self.grace; + tokio::spawn(async move { kill_within(&control, query_id, grace).await }); } async fn gave_up(&self) { @@ -329,6 +326,27 @@ mod tests { use super::*; + #[tokio::test] + async fn a_kill_the_server_never_answers_gives_up_at_the_grace() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("a listener"); + let address = listener.local_addr().expect("an address"); + let silent = tokio::spawn(async move { + let mut held = Vec::new(); + while let Ok((socket, _)) = listener.accept().await { + held.push(socket); + } + }); + let control = clickhouse::Client::default().with_url(format!("http://{address}")); + + let killing = kill_within(&control, Uuid::new_v4(), Duration::from_millis(200)); + + assert!( + tokio::time::timeout(Duration::from_secs(10), killing).await.is_ok(), + "the KILL waited on a server that never answered" + ); + silent.abort(); + } + #[test] fn only_a_body_the_driver_could_not_read_is_killed_after_it_fails() { let refused = DriverError::reported(ServerDiagnostics::new( From 3d1f37ab9c26a39f09a000b3183fde03ba6ecf76 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:27:40 +0700 Subject: [PATCH 008/178] fix(linux-clickhouse): send no further query once a call is stopped --- .../drivers/clickhouse/src/call_client.rs | 64 ++++++++++++++++--- linux/crates/drivers/clickhouse/src/cancel.rs | 14 ++++ .../drivers/clickhouse/src/catalog/mod.rs | 6 +- .../src/catalog/table_definition.rs | 2 +- .../clickhouse/src/commit_resolution.rs | 10 +-- .../drivers/clickhouse/src/connection.rs | 51 ++++++++++++--- .../crates/drivers/clickhouse/src/metadata.rs | 10 +-- .../drivers/clickhouse/src/stream_read.rs | 4 +- .../drivers/clickhouse/src/write_batch.rs | 9 +-- 9 files changed, 132 insertions(+), 38 deletions(-) diff --git a/linux/crates/drivers/clickhouse/src/call_client.rs b/linux/crates/drivers/clickhouse/src/call_client.rs index 328d102dfe..61d92cc536 100644 --- a/linux/crates/drivers/clickhouse/src/call_client.rs +++ b/linux/crates/drivers/clickhouse/src/call_client.rs @@ -3,6 +3,7 @@ use std::time::Duration; use clickhouse::query::Query; use tablepro_core::statement::Statement; +use tablepro_core::{DriverError, StopRequest}; use tokio::time::Instant; use uuid::Uuid; @@ -11,37 +12,46 @@ use crate::param_value::bind_params; /// The client one call sends its queries through, each under a query id /// of its own that the call's stop can name. +/// +/// A KILL stops only the query running when it arrives, so a call of +/// several queries would go on to the next one after a stop. Once the +/// call is stopped, no further query is handed out. pub(crate) struct CallClient { client: clickhouse::Client, stop: Arc, + request: StopRequest, } impl CallClient { - pub(crate) fn new(client: clickhouse::Client, stop: Arc) -> Self { - Self { client, stop } + pub(crate) fn new(client: clickhouse::Client, stop: Arc, request: StopRequest) -> Self { + Self { client, stop, request } } /// SQL sent as written: a `?` in it is the user's, not a marker. - pub(crate) fn raw(&self, sql: &str) -> Query { + pub(crate) fn raw(&self, sql: &str) -> Result { self.tracked(self.client.query_raw(sql)) } /// A generated statement, with its values bound as the server-side /// parameters its `{pN:Type}` markers name. - pub(crate) fn statement(&self, statement: &Statement) -> Query { - bind_params(self.raw(statement.sql()), statement.params()) + pub(crate) fn statement(&self, statement: &Statement) -> Result { + Ok(bind_params(self.raw(statement.sql())?, statement.params())) } /// SQL whose `?` markers the client fills, for identifiers bound /// through `clickhouse::sql::Identifier`. - pub(crate) fn template(&self, sql: &str) -> Query { + pub(crate) fn template(&self, sql: &str) -> Result { self.tracked(self.client.query(sql)) } - fn tracked(&self, query: Query) -> Query { + fn tracked(&self, query: Query) -> Result { + if self.request.is_requested() { + self.stop.withhold(); + return Err(self.stop.interrupted(&self.request)); + } let id = Uuid::new_v4(); self.stop.track(id); - query.with_setting("query_id", id.to_string()) + Ok(query.with_setting("query_id", id.to_string())) } } @@ -79,8 +89,46 @@ pub(crate) fn whole_seconds(bound: Option) -> u64 { #[cfg(test)] mod tests { + use tablepro_core::StopReason; + use super::*; + fn call(request: &StopRequest) -> (CallClient, Arc) { + let stop = Arc::new(ClickHouseStop::new(clickhouse::Client::default())); + let calls = CallClient::new(clickhouse::Client::default(), stop.clone(), request.clone()); + (calls, stop) + } + + #[test] + fn a_query_goes_out_until_the_call_is_stopped() { + let request = StopRequest::new(); + let (calls, stop) = call(&request); + + assert!(calls.raw("SELECT 1").is_ok()); + assert!(!stop.withheld()); + request.request(StopReason::Cancelled); + + assert_eq!(calls.raw("SELECT 2").err(), Some(DriverError::Cancelled)); + assert!(calls.template("SHOW CREATE TABLE ?.?").is_err()); + assert!(stop.withheld()); + } + + #[test] + fn a_withheld_query_reads_as_what_stopped_the_call() { + let request = StopRequest::new(); + let (calls, _stop) = call(&request); + + request.request(StopReason::DeadlineElapsed); + + assert_eq!( + calls.raw("SELECT 1").err(), + Some(DriverError::Timeout { + phase: tablepro_core::TimeoutPhase::Statement, + server_cancelled: false, + }) + ); + } + #[test] fn a_bound_rounds_up_to_whole_seconds_and_never_reaches_zero() { assert_eq!(whole_seconds(None), 0); diff --git a/linux/crates/drivers/clickhouse/src/cancel.rs b/linux/crates/drivers/clickhouse/src/cancel.rs index 40a790b3da..df41287ccc 100644 --- a/linux/crates/drivers/clickhouse/src/cancel.rs +++ b/linux/crates/drivers/clickhouse/src/cancel.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, MutexGuard, PoisonError}; use async_trait::async_trait; @@ -30,6 +31,7 @@ pub(crate) struct ClickHouseStop { control: clickhouse::Client, running: Mutex>, progress: Mutex, + withheld: AtomicBool, } impl ClickHouseStop { @@ -38,6 +40,7 @@ impl ClickHouseStop { control, running: Mutex::new(None), progress: Mutex::new(StopProgress::NotSent), + withheld: AtomicBool::new(false), } } @@ -56,6 +59,17 @@ impl ClickHouseStop { *lock(&self.progress) } + /// The call ended at a stop without sending its next query. + pub(crate) fn withhold(&self) { + self.withheld.store(true, Ordering::SeqCst); + } + + /// Whether the call ended by not sending a query. That was the + /// driver's own doing, so it says nothing about the route. + pub(crate) fn withheld(&self) -> bool { + self.withheld.load(Ordering::SeqCst) + } + /// What a call stopped before it reached the server reads as. pub(crate) fn interrupted(&self, request: &StopRequest) -> DriverError { match request.reason() { diff --git a/linux/crates/drivers/clickhouse/src/catalog/mod.rs b/linux/crates/drivers/clickhouse/src/catalog/mod.rs index 52369579fb..8368a02e73 100644 --- a/linux/crates/drivers/clickhouse/src/catalog/mod.rs +++ b/linux/crates/drivers/clickhouse/src/catalog/mod.rs @@ -43,14 +43,14 @@ pub(crate) async fn column_rows( calls: &CallClient, table: &CatalogTable, ) -> Result, DriverError> { - let result = read_table(table.bind(calls.raw(COLUMN_SELECT)), None).await?; + let result = read_table(table.bind(calls.raw(COLUMN_SELECT)?), None).await?; result.rows.iter().map(|row| ClickHouseColumnRow::read(row)).collect() } pub(crate) async fn table_keys(calls: &CallClient, table: &CatalogTable) -> Result { let sql = "SELECT sorting_key, primary_key, total_rows FROM system.tables \ WHERE database = {db:String} AND name = {tbl:String}"; - let result = read_table(table.bind(calls.raw(sql)), None).await?; + let result = read_table(table.bind(calls.raw(sql)?), None).await?; let Some(row) = result.rows.first() else { return Ok(TableKeys::default()); }; @@ -80,7 +80,7 @@ pub(crate) async fn fetch_indexes(calls: &CallClient, table: &CatalogTable) -> R } let sql = "SELECT name, expr FROM system.data_skipping_indices \ WHERE database = {db:String} AND table = {tbl:String} ORDER BY name"; - let skipping = read_table(table.bind(calls.raw(sql)), None).await?; + let skipping = read_table(table.bind(calls.raw(sql)?), None).await?; for row in &skipping.rows { let row = CatalogRow(row); indexes.push(IndexInfo { diff --git a/linux/crates/drivers/clickhouse/src/catalog/table_definition.rs b/linux/crates/drivers/clickhouse/src/catalog/table_definition.rs index cd17209b6a..aca01745e9 100644 --- a/linux/crates/drivers/clickhouse/src/catalog/table_definition.rs +++ b/linux/crates/drivers/clickhouse/src/catalog/table_definition.rs @@ -50,7 +50,7 @@ pub(crate) async fn fetch_table_definition( /// identifier by the client rather than quoted here. async fn create_statement(calls: &CallClient, catalog: &CatalogTable) -> Result, DriverError> { let query = calls - .template("SHOW CREATE TABLE ?.?") + .template("SHOW CREATE TABLE ?.?")? .bind(Identifier(&catalog.database)) .bind(Identifier(&catalog.name)); let result = read_table(query, None).await?; diff --git a/linux/crates/drivers/clickhouse/src/commit_resolution.rs b/linux/crates/drivers/clickhouse/src/commit_resolution.rs index 58237fcb85..c174f8656e 100644 --- a/linux/crates/drivers/clickhouse/src/commit_resolution.rs +++ b/linux/crates/drivers/clickhouse/src/commit_resolution.rs @@ -139,7 +139,7 @@ pub(crate) async fn resolve(calls: &CallClient, token: &CommitToken) -> Result {} Err(error) if server_code(&error) == Some(code::ACCESS_DENIED) => {} Err(error) => return Err(error), @@ -160,7 +160,7 @@ pub(crate) async fn resolve(calls: &CallClient, token: &CommitToken) -> Result Result, DriverError> { - let query = calls.raw(LOG_SELECT).param("id", query_id.to_string()); + let query = calls.raw(LOG_SELECT)?.param("id", query_id.to_string()); let result = read_table(query, None).await?; result .rows @@ -184,7 +184,7 @@ async fn log_entries(calls: &CallClient, query_id: Uuid) -> Result /// lists it, and lost otherwise. async fn still_running(calls: &CallClient, query_id: Uuid) -> Result { let query = calls - .raw("SELECT count() FROM system.processes WHERE query_id = {id:String}") + .raw("SELECT count() FROM system.processes WHERE query_id = {id:String}")? .param("id", query_id.to_string()); let result = read_table(query, None).await?; let running = result @@ -211,10 +211,10 @@ async fn mutation( Some(table) => calls .raw(&format!( "SELECT is_done FROM system.mutations WHERE database = {{db:String}} AND table = {{tbl:String}} AND {window}" - )) + ))? .param("db", table.database.as_str()) .param("tbl", table.table.as_str()), - None => calls.raw(&format!("SELECT is_done FROM system.mutations WHERE {window}")), + None => calls.raw(&format!("SELECT is_done FROM system.mutations WHERE {window}"))?, }; let query = query.param("started", started).param("ended", ended); let result = read_table(query, None).await?; diff --git a/linux/crates/drivers/clickhouse/src/connection.rs b/linux/crates/drivers/clickhouse/src/connection.rs index 1a906004c1..3273e4b7b7 100644 --- a/linux/crates/drivers/clickhouse/src/connection.rs +++ b/linux/crates/drivers/clickhouse/src/connection.rs @@ -119,15 +119,15 @@ impl ClickHouseConnection { Client::Reads => reading(self.endpoint.workspace(), read_bound(options.deadline(), bound)), Client::Control => self.endpoint.control().clone(), }; - let calls = CallClient::new(base, stop.clone()); + let work_stop = stop.clone(); let outcome = self .calls .run_read( options, HttpCall::new(), stop.clone(), - move |call, _request| async move { - let result = work(calls).await; + move |call, request| async move { + let result = work(CallClient::new(base, work_stop, request)).await; (call, result) }, ) @@ -160,10 +160,9 @@ impl ClickHouseConnection { if !suspects_link(&error) { return Err(error); } - let lost = match stop.progress() { - StopProgress::InFlight | StopProgress::Unreachable => true, - StopProgress::Delivered => false, - StopProgress::NotSent | StopProgress::NothingToStop => { + let lost = match known_lost(stop.progress(), stop.withheld()) { + Some(lost) => lost, + None => { let checked = self.endpoint.check(self.liveness().cancel_grace).await; checked.is_err_and(|failure| means_link_lost(&failure)) } @@ -193,6 +192,21 @@ fn suspects_link(error: &DriverError) -> bool { } } +/// What the stop already says about the route, or `None` when only a +/// request of its own can tell. +/// +/// A call that waits on its KILL and ends with it still out means the +/// route stopped answering. A call that ended by holding back its next +/// query waited on nothing, so a KILL still out says nothing yet. +fn known_lost(progress: StopProgress, withheld: bool) -> Option { + match progress { + StopProgress::Unreachable => Some(true), + StopProgress::InFlight if !withheld => Some(true), + StopProgress::Delivered => Some(false), + StopProgress::InFlight | StopProgress::NotSent | StopProgress::NothingToStop => None, + } +} + fn means_link_lost(error: &DriverError) -> bool { matches!( error.category(), @@ -324,7 +338,7 @@ impl Connection for ClickHouseConnection { ) -> Result { let statement = statement.clone(); self.workspace_read(options, move |calls| async move { - let table = read_table(calls.statement(&statement), Some(limit.get())).await?; + let table = read_table(calls.statement(&statement)?, Some(limit.get())).await?; Ok(QueryResult::new(table.columns, table.rows).truncated(table.truncated)) }) .await @@ -333,7 +347,7 @@ impl Connection for ClickHouseConnection { async fn count_rows(&self, statement: &Statement, options: CallOptions) -> Result { let statement = statement.clone(); self.workspace_read(options, move |calls| async move { - let table = read_table(calls.statement(&statement), Some(1)).await?; + let table = read_table(calls.statement(&statement)?, Some(1)).await?; let value = table .rows .first() @@ -370,7 +384,7 @@ impl Connection for ClickHouseConnection { let plan = WritePlan { batch: batch.clone(), ledger: ledger.clone(), - probes: CallClient::new(reading(self.endpoint.workspace(), probe_bound), stop.clone()), + probes: reading(self.endpoint.workspace(), probe_bound), writes: self .endpoint .workspace() @@ -422,3 +436,20 @@ impl Connection for ClickHouseConnection { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_kill_still_out_after_a_held_back_query_is_not_a_lost_route() { + assert_eq!(known_lost(StopProgress::InFlight, true), None); + assert_eq!(known_lost(StopProgress::Unreachable, true), Some(true)); + assert_eq!(known_lost(StopProgress::Delivered, true), Some(false)); + assert_eq!(known_lost(StopProgress::InFlight, false), Some(true)); + assert_eq!(known_lost(StopProgress::Unreachable, false), Some(true)); + assert_eq!(known_lost(StopProgress::Delivered, false), Some(false)); + assert_eq!(known_lost(StopProgress::NotSent, false), None); + assert_eq!(known_lost(StopProgress::NothingToStop, false), None); + } +} diff --git a/linux/crates/drivers/clickhouse/src/metadata.rs b/linux/crates/drivers/clickhouse/src/metadata.rs index 229e175236..7a1afdd965 100644 --- a/linux/crates/drivers/clickhouse/src/metadata.rs +++ b/linux/crates/drivers/clickhouse/src/metadata.rs @@ -10,7 +10,7 @@ const SYSTEM_DATABASES: [&str; 3] = ["system", "INFORMATION_SCHEMA", "informatio /// Every database, with the session's own as the default. pub(crate) async fn list_schemas(calls: &CallClient) -> Result, DriverError> { let sql = "SELECT name, name = currentDatabase() FROM system.databases ORDER BY name"; - let result = read_table(calls.raw(sql), None).await?; + let result = read_table(calls.raw(sql)?, None).await?; result .rows .iter() @@ -38,15 +38,15 @@ pub(crate) async fn list_objects( let base = "SELECT database, name, engine FROM system.tables WHERE is_temporary = 0"; let query = match scope { ObjectScope::DefaultSchema => calls - .raw(&format!("{base} AND database = {{db:String}} ORDER BY name")) + .raw(&format!("{base} AND database = {{db:String}} ORDER BY name"))? .param("db", default_database), ObjectScope::Schema(database) => calls - .raw(&format!("{base} AND database = {{db:String}} ORDER BY name")) + .raw(&format!("{base} AND database = {{db:String}} ORDER BY name"))? .param("db", database.as_str()), ObjectScope::AllSchemas => calls .raw(&format!( "{base} AND database NOT IN {{system:Array(String)}} ORDER BY database, name" - )) + ))? .param("system", SYSTEM_DATABASES), }; let mut objects = Vec::new(); @@ -60,7 +60,7 @@ pub(crate) async fn list_objects( } if !matches!(scope, ObjectScope::Schema(_)) { let sql = "SELECT name FROM system.functions WHERE origin = 'SQLUserDefined' ORDER BY name"; - for row in &read_table(calls.raw(sql), None).await?.rows { + for row in &read_table(calls.raw(sql)?, None).await?.rows { objects.push(ObjectInfo { schema: None, name: CatalogRow(row).text(0)?, diff --git a/linux/crates/drivers/clickhouse/src/stream_read.rs b/linux/crates/drivers/clickhouse/src/stream_read.rs index 16916100fa..8382de6497 100644 --- a/linux/crates/drivers/clickhouse/src/stream_read.rs +++ b/linux/crates/drivers/clickhouse/src/stream_read.rs @@ -14,8 +14,8 @@ pub(crate) async fn stream( sink: &mut dyn ResultSink, ) -> Result { let query = match read { - ReadStatement::Generated(statement) => calls.statement(statement), - ReadStatement::User(sql) => calls.raw(sql.as_str()), + ReadStatement::Generated(statement) => calls.statement(statement)?, + ReadStatement::User(sql) => calls.raw(sql.as_str())?, }; let mut reader = ResultReader::open(query)?; let mut emitter = Emitter { diff --git a/linux/crates/drivers/clickhouse/src/write_batch.rs b/linux/crates/drivers/clickhouse/src/write_batch.rs index c8b33cf2b2..b1bf4de899 100644 --- a/linux/crates/drivers/clickhouse/src/write_batch.rs +++ b/linux/crates/drivers/clickhouse/src/write_batch.rs @@ -37,7 +37,7 @@ pub(crate) struct WritePlan { pub(crate) batch: WriteBatch, pub(crate) ledger: Arc, /// Reads that may be stopped, for the probes. - pub(crate) probes: CallClient, + pub(crate) probes: clickhouse::Client, /// Waits for each statement to finish, and never bounds it: a /// mutation the server gives up waiting on keeps running anyway. pub(crate) writes: clickhouse::Client, @@ -54,9 +54,10 @@ pub(crate) struct WritePlan { pub(crate) async fn perform( plan: &WritePlan, request: &StopRequest, - stop: &ClickHouseStop, + stop: &Arc, ) -> Result { let ledger = &plan.ledger; + let probes = CallClient::new(plan.probes.clone(), stop.clone(), request.clone()); for (index, step) in plan.batch.steps.iter().enumerate() { let Some((probe, expected)) = step.guard.probe() else { continue; @@ -64,7 +65,7 @@ pub(crate) async fn perform( if request.is_requested() { return Err(ledger.interrupted(stop.interrupted(request), SessionFate::RolledBack)); } - match count(&plan.probes, probe).await { + match count(&probes, probe).await { Ok(actual) if actual == expected => {} Ok(actual) => return Err(ledger.guard_failed(index, expected, actual, SessionFate::RolledBack)), Err(error) if request.is_requested() && acknowledges_stop(&error) => { @@ -131,7 +132,7 @@ async fn execute( } async fn count(calls: &CallClient, probe: &Statement) -> Result { - let result = read_table(calls.statement(probe), None).await?; + let result = read_table(calls.statement(probe)?, None).await?; let value = result .rows .first() From 571501005f2e3c45867ed1156b9001b2f97b0695 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:27:46 +0700 Subject: [PATCH 009/178] fix(linux-clickhouse): look a mutation up in the table the save wrote --- linux/CHANGELOG.md | 1 + linux/crates/core/src/commit.rs | 6 + .../clickhouse/src/commit_resolution.rs | 68 +++++++++--- .../drivers/clickhouse/src/connection.rs | 2 + .../drivers/clickhouse/src/write_batch.rs | 4 + .../clickhouse/tests/integration/support.rs | 7 ++ .../clickhouse/tests/integration/writes.rs | 103 ++++++++++++++++-- 7 files changed, 165 insertions(+), 26 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 6cb304c743..5830b30758 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -19,3 +19,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. - ClickHouse query or export with a FORMAT clause filled memory instead of failing. +- ClickHouse save with an unknown outcome could read as applied because of another table's mutation. diff --git a/linux/crates/core/src/commit.rs b/linux/crates/core/src/commit.rs index 14b17c6a68..b3b09c3dd8 100644 --- a/linux/crates/core/src/commit.rs +++ b/linux/crates/core/src/commit.rs @@ -1,3 +1,5 @@ +use crate::meta::TableRef; + /// What the app needs to ask the server whether a commit landed. /// /// A connection lost between COMMIT and its acknowledgement leaves the @@ -12,9 +14,13 @@ pub enum CommitToken { PostgresXid { xid: u64, legacy_txid: bool }, /// ClickHouse has no transactions, so the token is the query ids /// that were sent, and how many of them are known to have finished. + /// `table` is the table they write, qualified: a mutation is looked + /// up by table, and the log of an ALTER that failed while waiting on + /// its mutation names none. ClickHouseQuery { query_ids: Vec, first_unconfirmed: usize, + table: TableRef, }, } diff --git a/linux/crates/drivers/clickhouse/src/commit_resolution.rs b/linux/crates/drivers/clickhouse/src/commit_resolution.rs index c174f8656e..18f31c4c9c 100644 --- a/linux/crates/drivers/clickhouse/src/commit_resolution.rs +++ b/linux/crates/drivers/clickhouse/src/commit_resolution.rs @@ -5,10 +5,12 @@ //! waits the query log has no entry for it at all, and a wait that fails //! is logged as ExceptionBeforeStart with no table named. The statement //! is therefore looked for in `system.processes` when the log is silent, -//! and its mutation in `system.mutations` by creation time when its ALTER -//! failed while the mutation may live on. `system.mutations` has no +//! and its mutation in `system.mutations` by table and creation time when +//! its ALTER failed while the mutation may live on. The table comes from +//! the write itself when the log names none. `system.mutations` has no //! column linking a mutation to its ALTER, so any doubt is Unresolvable. +use tablepro_core::meta::TableRef; use tablepro_core::{CommitResolution, CommitToken, DriverError, Value}; use uuid::Uuid; @@ -105,6 +107,19 @@ fn mutated_table(entry: &LogEntry) -> Option { }) } +/// The table to look for the mutation in: the log's where it names one, +/// otherwise the one the write was for. Searching every table instead +/// would take another session's mutation in the same seconds for this +/// one. +pub(crate) fn mutation_table(logged: Option, written: &TableRef) -> Option { + logged.or_else(|| { + Some(MutatedTable { + database: written.schema.clone().filter(|database| !database.is_empty())?, + table: written.name.clone(), + }) + }) +} + /// One mutation in the window is the ALTER's; none means someone ran /// KILL MUTATION, which removes the row but keeps the parts it already /// changed, and more than one cannot be told apart. @@ -132,6 +147,7 @@ pub(crate) async fn resolve(calls: &CallClient, token: &CommitToken) -> Result Result resolution, LogVerdict::NotEnded => still_running(calls, *query_id).await?, - LogVerdict::Mutation { table, started, ended } => mutation(calls, table.as_ref(), &started, &ended).await?, + LogVerdict::Mutation { table, started, ended } => match mutation_table(table, written) { + Some(table) => mutation(calls, &table, &started, &ended).await?, + None => CommitResolution::Unresolvable, + }, }; Ok(compose(*first_unconfirmed, query_ids.len(), statement)) } @@ -202,21 +221,19 @@ async fn still_running(calls: &CallClient, query_id: Uuid) -> Result, + table: &MutatedTable, started: &str, ended: &str, ) -> Result { - let window = "create_time >= {started:DateTime} AND create_time <= {ended:DateTime}"; - let query = match table { - Some(table) => calls - .raw(&format!( - "SELECT is_done FROM system.mutations WHERE database = {{db:String}} AND table = {{tbl:String}} AND {window}" - ))? - .param("db", table.database.as_str()) - .param("tbl", table.table.as_str()), - None => calls.raw(&format!("SELECT is_done FROM system.mutations WHERE {window}"))?, - }; - let query = query.param("started", started).param("ended", ended); + let query = calls + .raw( + "SELECT is_done FROM system.mutations WHERE database = {db:String} AND table = {tbl:String} \ + AND create_time >= {started:DateTime} AND create_time <= {ended:DateTime}", + )? + .param("db", table.database.as_str()) + .param("tbl", table.table.as_str()) + .param("started", started) + .param("ended", ended); let result = read_table(query, None).await?; let done = result .rows @@ -335,6 +352,27 @@ mod tests { } } + #[test] + fn a_mutation_is_looked_for_in_the_logged_table_then_the_written_one() { + let written = TableRef::new(Some("tablepro".to_owned()), "orders"); + let orders = Some(MutatedTable { + database: "tablepro".to_owned(), + table: "orders".to_owned(), + }); + + assert_eq!(mutation_table(events_table(), &written), events_table()); + assert_eq!( + mutation_table(None, &written), + orders, + "an ALTER whose wait failed names no table" + ); + assert_eq!( + mutation_table(None, &TableRef::unqualified("orders")), + None, + "a table without its database could be any database's" + ); + } + #[test] fn a_statement_answer_composes_over_the_batch() { use CommitResolution::{Applied, NotApplied, PartiallyApplied, StillRunning, Unresolvable}; diff --git a/linux/crates/drivers/clickhouse/src/connection.rs b/linux/crates/drivers/clickhouse/src/connection.rs index 3273e4b7b7..103bacf63a 100644 --- a/linux/crates/drivers/clickhouse/src/connection.rs +++ b/linux/crates/drivers/clickhouse/src/connection.rs @@ -381,8 +381,10 @@ impl Connection for ClickHouseConnection { let stop = Arc::new(ClickHouseStop::new(self.endpoint.control().clone())); let ledger = Arc::new(WriteLedger::new(WriteAtomicity::StatementByStatement)); let probe_bound = read_bound(options.deadline(), Some(self.settings.statement_timeout)); + let written = self.catalog_table(&batch.table); let plan = WritePlan { batch: batch.clone(), + table: TableRef::new(Some(written.database), written.name), ledger: ledger.clone(), probes: reading(self.endpoint.workspace(), probe_bound), writes: self diff --git a/linux/crates/drivers/clickhouse/src/write_batch.rs b/linux/crates/drivers/clickhouse/src/write_batch.rs index b1bf4de899..d71d349869 100644 --- a/linux/crates/drivers/clickhouse/src/write_batch.rs +++ b/linux/crates/drivers/clickhouse/src/write_batch.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use clickhouse::error::Error as ClickHouseError; +use tablepro_core::meta::TableRef; use tablepro_core::statement::{RowGuard, Statement}; use tablepro_core::{ CommitToken, DriverError, ReadOnlyRefusal, SessionFate, StopRequest, WriteBatch, WriteLedger, WriteReport, @@ -35,6 +36,8 @@ pub(crate) fn precheck(batch: &WriteBatch, read_only: bool) -> Result<(), Driver /// Everything one write needs once its task runs. pub(crate) struct WritePlan { pub(crate) batch: WriteBatch, + /// The batch's table, qualified, for the commit token. + pub(crate) table: TableRef, pub(crate) ledger: Arc, /// Reads that may be stopped, for the probes. pub(crate) probes: clickhouse::Client, @@ -84,6 +87,7 @@ pub(crate) async fn perform( ledger.attach_token(CommitToken::ClickHouseQuery { query_ids: query_ids.clone(), first_unconfirmed: index, + table: plan.table.clone(), }); ledger.statement_sending(index); match execute(&plan.writes, &step.statement, *query_id).await { diff --git a/linux/crates/drivers/clickhouse/tests/integration/support.rs b/linux/crates/drivers/clickhouse/tests/integration/support.rs index f07d604865..ac33d64396 100644 --- a/linux/crates/drivers/clickhouse/tests/integration/support.rs +++ b/linux/crates/drivers/clickhouse/tests/integration/support.rs @@ -145,6 +145,13 @@ pub fn batch(statements: &[&str]) -> WriteBatch { ) } +/// Row changes to the table `name` in the test database. +pub fn batch_on(name: &str, statements: &[&str]) -> WriteBatch { + let mut batch = batch(statements); + batch.table = table(name); + batch +} + /// The save the grid would plan for `changes` on `table`. pub async fn planned(connection: &dyn Connection, table: &TableRef, changes: &ChangeSet) -> WriteBatch { let columns = connection diff --git a/linux/crates/drivers/clickhouse/tests/integration/writes.rs b/linux/crates/drivers/clickhouse/tests/integration/writes.rs index 97162172cd..f09c159a24 100644 --- a/linux/crates/drivers/clickhouse/tests/integration/writes.rs +++ b/linux/crates/drivers/clickhouse/tests/integration/writes.rs @@ -11,7 +11,7 @@ use tablepro_core::{ use tokio::time::Instant; use crate::support::{ - CALL, DATABASE, admin, apply, batch, connect, count, flush_logs, planned, quoted, run, scalar, session, setup, + CALL, DATABASE, admin, apply, batch_on, connect, count, flush_logs, planned, quoted, run, scalar, session, setup, start, table, text, unique, within, }; @@ -197,10 +197,14 @@ async fn write_statements_never_carry_max_execution_time() { async fn alter_update_deadline_commit_outcome_unknown_resolves_still_running_then_applied() { let fixture = start().await; let connection = connect(&fixture, false).await; - let qualified = slow_table(connection.as_ref(), &unique("deadline")).await; + let name = unique("deadline"); + let qualified = slow_table(connection.as_ref(), &name).await; let outcome = connection - .write_batch(&batch(&[&slow_update(&qualified)]), within(Duration::from_secs(1))) + .write_batch( + &batch_on(&name, &[&slow_update(&qualified)]), + within(Duration::from_secs(1)), + ) .await; let Err(DriverError::CommitOutcomeUnknown { token: Some(token), .. }) = outcome else { @@ -233,8 +237,8 @@ async fn alter_update_killed_by_other_session_is_unknown_not_cancelled() { let writing = { let connection = connection.clone(); - let update = slow_update(&qualified); - tokio::spawn(async move { connection.write_batch(&batch(&[&update]), within(CALL)).await }) + let update = batch_on(&name, &[&slow_update(&qualified)]); + tokio::spawn(async move { connection.write_batch(&update, within(CALL)).await }) }; let monitor = session(connection.as_ref()).await; let find = format!( @@ -288,12 +292,15 @@ async fn failing_mutation_unfinished_is_unknown_and_still_running() { ) .await; - let outcome = apply( - connection.as_ref(), - WriteKind::RowChanges, - &[&format!("ALTER TABLE {qualified} UPDATE n = toUInt8(s) WHERE 1")], - ) - .await; + let outcome = connection + .write_batch( + &batch_on( + &name, + &[&format!("ALTER TABLE {qualified} UPDATE n = toUInt8(s) WHERE 1")], + ), + within(CALL), + ) + .await; let Err(DriverError::CommitOutcomeUnknown { token: Some(token), .. }) = outcome else { panic!("a failing mutation gave {outcome:?}"); @@ -310,6 +317,77 @@ async fn failing_mutation_unfinished_is_unknown_and_still_running() { connection.close().await.expect("a close"); } +/// The ALTER's mutation never runs and is killed, while another table's +/// mutation lands inside the ALTER's window. Its log names no table, so +/// only the table the write was for can keep the other one out. +#[tokio::test] +#[ignore = "requires docker"] +async fn killed_mutation_unresolvable_despite_another_tables_mutation_in_its_window() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let held = unique("held"); + let other = unique("other"); + let (held_table, other_table) = (quoted(&held), quoted(&other)); + setup( + connection.as_ref(), + &[ + &format!("CREATE TABLE {held_table} (id Int32, n Int32) ENGINE = MergeTree ORDER BY id"), + &format!("INSERT INTO {held_table} VALUES (1, 0)"), + &format!("CREATE TABLE {other_table} (id Int32, n Int32) ENGINE = MergeTree ORDER BY id"), + &format!("INSERT INTO {other_table} VALUES (1, 0)"), + ], + ) + .await; + admin(connection.as_ref(), &format!("SYSTEM STOP MERGES {held_table}")).await; + + let writing = { + let connection = connection.clone(); + let update = batch_on(&held, &[&format!("ALTER TABLE {held_table} UPDATE n = 1 WHERE 1")]); + tokio::spawn(async move { connection.write_batch(&update, within(CALL)).await }) + }; + let waiting = format!( + "SELECT count() FROM system.processes WHERE query LIKE 'ALTER TABLE%{held}%' \ + AND query NOT LIKE '%system.processes%'" + ); + let give_up = Instant::now() + Duration::from_secs(10); + while count(connection.as_ref(), &waiting).await == 0 { + assert!( + Instant::now() < give_up, + "the ALTER never showed up in system.processes" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + admin( + connection.as_ref(), + &format!("ALTER TABLE {other_table} UPDATE n = 1 WHERE 1"), + ) + .await; + admin( + connection.as_ref(), + &format!("KILL MUTATION WHERE database = '{DATABASE}' AND table = '{held}' SYNC"), + ) + .await; + + let outcome = writing.await.expect("the write task"); + let Err(DriverError::CommitOutcomeUnknown { token: Some(token), .. }) = outcome else { + panic!("a mutation killed under its ALTER gave {outcome:?}"); + }; + assert_eq!( + count( + connection.as_ref(), + &format!("SELECT count() FROM system.mutations WHERE table = '{other}' AND is_done") + ) + .await, + 1, + "the other table's mutation did not land" + ); + assert_eq!( + resolution(connection.as_ref(), &token).await, + CommitResolution::Unresolvable + ); + connection.close().await.expect("a close"); +} + #[tokio::test] #[ignore = "requires docker"] async fn resolve_finished_insert_applied() { @@ -334,6 +412,7 @@ async fn resolve_finished_insert_applied() { let token = CommitToken::ClickHouseQuery { query_ids: vec![query_id], first_unconfirmed: 0, + table: table(&name), }; assert_eq!(resolution(connection.as_ref(), &token).await, CommitResolution::Applied); @@ -348,6 +427,7 @@ async fn resolve_random_id_unresolvable() { let token = CommitToken::ClickHouseQuery { query_ids: vec![uuid::Uuid::new_v4()], first_unconfirmed: 0, + table: table("t"), }; assert_eq!( @@ -390,6 +470,7 @@ async fn resolve_syntax_error_insert_not_applied() { let token = CommitToken::ClickHouseQuery { query_ids: vec![query_id], first_unconfirmed: 0, + table: table(&name), }; assert_eq!( From 855e0c2c87ffc115197ccbdf9ffebf018fe02fca Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:27:46 +0700 Subject: [PATCH 010/178] fix(linux-clickhouse): report an expired editor session as lost --- linux/CHANGELOG.md | 1 + .../drivers/clickhouse/src/editor_run.rs | 9 +++ .../drivers/clickhouse/src/editor_session.rs | 75 ++++++++++++++++++- .../crates/drivers/clickhouse/src/endpoint.rs | 17 ++++- linux/crates/drivers/clickhouse/src/errors.rs | 1 + 5 files changed, 98 insertions(+), 5 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 5830b30758..68a1db5feb 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -19,4 +19,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. - ClickHouse query or export with a FORMAT clause filled memory instead of failing. +- ClickHouse editor tab silently lost its temporary tables and settings when the server expired its session. - ClickHouse save with an unknown outcome could read as applied because of another table's mutation. diff --git a/linux/crates/drivers/clickhouse/src/editor_run.rs b/linux/crates/drivers/clickhouse/src/editor_run.rs index dfc0cd3e03..983d41c95c 100644 --- a/linux/crates/drivers/clickhouse/src/editor_run.rs +++ b/linux/crates/drivers/clickhouse/src/editor_run.rs @@ -12,6 +12,7 @@ use uuid::Uuid; use crate::call_client::whole_seconds; use crate::cancel::{acknowledges_stop, kill_query}; +use crate::errors::{code, server_code}; use crate::result_reader::{ReadEvent, ResultReader}; /// Rows per `ResultEvent::Rows`. The sink is awaited per batch, so a slow @@ -104,6 +105,14 @@ impl EditorRun { during: LossPhase::Statement, }); } + // The server let the session expire, and with it what the + // tab built up there; carrying on in a new one would hide + // that. + Err(error) if server_code(&error) == Some(code::SESSION_NOT_FOUND) => { + return Err(DriverError::ConnectionLost { + during: LossPhase::Idle, + }); + } Err(DriverError::Timeout { phase: TimeoutPhase::Statement, server_cancelled, diff --git a/linux/crates/drivers/clickhouse/src/editor_session.rs b/linux/crates/drivers/clickhouse/src/editor_session.rs index b48b090482..33a78ab98d 100644 --- a/linux/crates/drivers/clickhouse/src/editor_session.rs +++ b/linux/crates/drivers/clickhouse/src/editor_session.rs @@ -55,7 +55,9 @@ impl ClickHouseEditorSession { } Ok(Arc::new(Self { id: EditorSessionId::new(), - session, + // Without the check, a run on a session the server let expire + // quietly starts a new, empty one under the same id. + session: session.with_setting("session_check", "1"), control: endpoint.control().clone(), statement_timeout: options.statement_timeout, grace: liveness.cancel_grace, @@ -128,3 +130,74 @@ impl EditorSession for ClickHouseEditorSession { self.status.lose(); } } + +#[cfg(test)] +mod tests { + use async_trait::async_trait; + use secrecy::SecretString; + use tablepro_core::{ + ConnectEnvironment, Credentials, NetworkEndpoint, NetworkTarget, ResultEvent, RunEnd, SinkFlow, TlsConfig, + }; + use tablepro_net::DirectTcpTransport; + use tablepro_test_fixtures::ClickHouseFixture; + use tablepro_test_support::conformance::test_liveness; + + use super::*; + + struct Discard; + + #[async_trait] + impl ResultSink for Discard { + async fn accept(&mut self, _event: ResultEvent) -> SinkFlow { + SinkFlow::Continue + } + } + + async fn run(session: &ClickHouseEditorSession, sql: &str) -> Result { + session + .run( + UserSql::new(sql), + RunOptions::new(CancellationToken::new()), + Box::new(Discard), + ) + .await + } + + #[tokio::test] + #[ignore = "requires docker"] + async fn a_session_the_server_let_expire_is_lost_not_replaced() { + let fixture = ClickHouseFixture::start().await.expect("a ClickHouse container"); + let liveness = test_liveness(); + let address = NetworkEndpoint::new(fixture.host(), fixture.http_port()).expect("an endpoint"); + let credentials = fixture.password_credentials(); + let target = NetworkTarget { + transport: Arc::new(DirectTcpTransport::new(address, &liveness)), + credentials: Credentials::Password { + username: credentials.username.clone(), + password: SecretString::from(credentials.password.clone()), + }, + database: Some("tablepro".to_owned()), + tls: TlsConfig::disabled(), + }; + let endpoint = ClickHouseEndpoint::new(&target, liveness, &ConnectEnvironment::new("test"), false) + .expect("an endpoint") + .with_session_timeout(Duration::from_secs(1)); + let session = + ClickHouseEditorSession::open(&endpoint, EditorSessionOptions::default(), CancellationToken::new()) + .await + .expect("an editor session"); + + let created = run(&session, "CREATE TEMPORARY TABLE scratch (x UInt8)").await; + assert_eq!(created.map(|summary| summary.end), Ok(RunEnd::Completed)); + tokio::time::sleep(Duration::from_secs(4)).await; + let outcome = run(&session, "SELECT count() FROM scratch").await; + + assert_eq!( + outcome.map(|summary| summary.end), + Err(DriverError::ConnectionLost { + during: LossPhase::Idle + }) + ); + assert_eq!(session.state(), EditorSessionState::Lost); + } +} diff --git a/linux/crates/drivers/clickhouse/src/endpoint.rs b/linux/crates/drivers/clickhouse/src/endpoint.rs index 9c697df431..6d75c008fa 100644 --- a/linux/crates/drivers/clickhouse/src/endpoint.rs +++ b/linux/crates/drivers/clickhouse/src/endpoint.rs @@ -11,7 +11,7 @@ use crate::errors::{ErrorContext, map_error_in}; use crate::result_reader::drain; /// How long the server keeps an editor session's state between runs. -const SESSION_TIMEOUT_SECONDS: &str = "3600"; +const SESSION_TIMEOUT: Duration = Duration::from_secs(3600); /// Everything needed to reach one server: the workspace's client, the /// sessionless control client that KILLs and resolves, and the @@ -22,6 +22,7 @@ pub(crate) struct ClickHouseEndpoint { transport: Arc, liveness: LivenessPolicy, tls_requested: bool, + session_timeout: Duration, } impl ClickHouseEndpoint { @@ -44,9 +45,16 @@ impl ClickHouseEndpoint { transport: target.transport.clone(), liveness, tls_requested: target.tls.mode != TlsMode::Disable, + session_timeout: SESSION_TIMEOUT, }) } + #[cfg(test)] + pub(crate) fn with_session_timeout(mut self, timeout: Duration) -> Self { + self.session_timeout = timeout; + self + } + /// The client of the workspace's own requests, read-only when the /// connection is. pub(crate) fn workspace(&self) -> &clickhouse::Client { @@ -63,13 +71,14 @@ impl ClickHouseEndpoint { &self.liveness } - /// The client of one editor session: the server keeps a session's - /// settings and temporary tables under its id. + /// The client that creates one editor session: the server keeps a + /// session's settings and temporary tables under its id, for as long + /// as the request that created it asked. pub(crate) fn session(&self, id: Uuid) -> clickhouse::Client { self.workspace .clone() .with_setting("session_id", id.to_string()) - .with_setting("session_timeout", SESSION_TIMEOUT_SECONDS) + .with_setting("session_timeout", self.session_timeout.as_secs().max(1).to_string()) } /// What the server says it is, and the database unqualified names diff --git a/linux/crates/drivers/clickhouse/src/errors.rs b/linux/crates/drivers/clickhouse/src/errors.rs index ca06befa74..e17dc431b6 100644 --- a/linux/crates/drivers/clickhouse/src/errors.rs +++ b/linux/crates/drivers/clickhouse/src/errors.rs @@ -19,6 +19,7 @@ pub(crate) mod code { pub(crate) const SOCKET_TIMEOUT: i32 = 209; pub(crate) const NETWORK_ERROR: i32 = 210; pub(crate) const UNFINISHED: i32 = 341; + pub(crate) const SESSION_NOT_FOUND: i32 = 372; pub(crate) const QUERY_IS_PROHIBITED: i32 = 392; pub(crate) const QUERY_WAS_CANCELLED: i32 = 394; pub(crate) const ACCESS_DENIED: i32 = 497; From c6f4a47a99cbe57d55cb8e150387b14ce6aabdd7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:27:47 +0700 Subject: [PATCH 011/178] fix(linux-clickhouse): pin the DateTime output format --- linux/CHANGELOG.md | 1 + .../drivers/clickhouse/src/client_factory.rs | 6 ++++- .../clickhouse/tests/integration/sessions.rs | 23 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 68a1db5feb..b711ac83d0 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -20,4 +20,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. - ClickHouse query or export with a FORMAT clause filled memory instead of failing. - ClickHouse editor tab silently lost its temporary tables and settings when the server expired its session. +- ClickHouse DateTime values showed as UTC text when the server profile or session set ISO output. - ClickHouse save with an unknown outcome could read as applied because of another table's mutation. diff --git a/linux/crates/drivers/clickhouse/src/client_factory.rs b/linux/crates/drivers/clickhouse/src/client_factory.rs index 3d8af4f0f2..275615edd1 100644 --- a/linux/crates/drivers/clickhouse/src/client_factory.rs +++ b/linux/crates/drivers/clickhouse/src/client_factory.rs @@ -23,10 +23,14 @@ const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(2); const CERTIFICATE_AUTH_HEADER: &str = "X-ClickHouse-SSL-Certificate-Auth"; /// The settings every request carries. -const COMMON_SETTINGS: [(&str, &str); 6] = [ +const COMMON_SETTINGS: [(&str, &str); 7] = [ // A Decimal is written at its column's scale, so a value read back // keeps the scale it was stored with and compares equal to it. ("output_format_decimal_trailing_zeros", "1"), + // A DateTime is read as `YYYY-MM-DD hh:mm:ss` in its own time zone. + // A profile or session that set `iso` would send it in UTC instead, + // and a save keyed on it would miss its row. + ("date_time_output_format", "simple"), // An error after the first rows is written as plain exception text, // which the clickhouse crate detects, rather than as one more row in // the output format, which would read as data. diff --git a/linux/crates/drivers/clickhouse/tests/integration/sessions.rs b/linux/crates/drivers/clickhouse/tests/integration/sessions.rs index 9859a74625..5f4c5fbee0 100644 --- a/linux/crates/drivers/clickhouse/tests/integration/sessions.rs +++ b/linux/crates/drivers/clickhouse/tests/integration/sessions.rs @@ -4,6 +4,7 @@ use std::time::Duration; use async_trait::async_trait; +use tablepro_core::value::Temporal; use tablepro_core::{ Connection, DriverError, ReadStatement, ResultEvent, ResultSink, RowLimit, RunEnd, RunOptions, SinkFlow, Truncation, UserSql, Value, @@ -235,3 +236,25 @@ async fn a_format_clause_fails_an_export_instead_of_filling_memory() { wait_until_gone(connection.as_ref(), &marker).await; connection.close().await.expect("a close"); } + +#[tokio::test] +#[ignore = "requires docker"] +async fn a_session_set_to_iso_still_reads_timestamps_in_their_own_zone() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let editor = session(connection.as_ref()).await; + + run_ok(editor.as_ref(), "SET date_time_output_format = 'iso'").await; + let rows = run_ok( + editor.as_ref(), + "SELECT toDateTime('2024-06-15 08:30:00', 'Asia/Tokyo')", + ) + .await; + + let stamp = chrono::NaiveDate::from_ymd_opt(2024, 6, 15) + .and_then(|date| date.and_hms_opt(8, 30, 0)) + .expect("a timestamp"); + assert_eq!(rows.rows(), vec![vec![Value::Timestamp(Temporal::Finite(stamp))]]); + editor.close().await; + connection.close().await.expect("a close"); +} From 6e0e09745ce0760af1d37ae75a78eccd89856f2b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:28:15 +0700 Subject: [PATCH 012/178] refactor(linux): give each window its own pending-edit trackers Row and DDL trackers move from thread_local registries to registries App owns, reached through handles that borrow only inside their own methods and return owned data. A row edit returns a TrackerDelta the browse tab applies to its list store, which replaces the relm4 tracker channel. --- linux/ARCHITECTURE.md | 14 +- linux/CHANGELOG.md | 3 + linux/crates/app/src/services/cell_overlay.rs | 14 + .../crates/app/src/services/change_tracker.rs | 727 +++++++++--------- .../src/services/change_tracker_registry.rs | 71 ++ linux/crates/app/src/services/mod.rs | 8 + .../app/src/services/structure_tracker.rs | 153 +--- .../src/services/structure_tracker_handle.rs | 29 + .../services/structure_tracker_registry.rs | 61 ++ linux/crates/app/src/services/tab_registry.rs | 52 ++ .../app/src/services/tab_tracker_handle.rs | 132 ++++ .../crates/app/src/services/tracker_delta.rs | 22 + linux/crates/app/src/services/tracker_edit.rs | 29 + linux/crates/app/src/ui/app/browse.rs | 14 +- linux/crates/app/src/ui/app/connection.rs | 2 +- linux/crates/app/src/ui/app/mod.rs | 48 +- linux/crates/app/src/ui/app/row_ops.rs | 17 +- linux/crates/app/src/ui/app/structure.rs | 40 +- linux/crates/app/src/ui/app/teardown_guard.rs | 4 +- linux/crates/app/src/ui/app/workspace_tabs.rs | 75 +- linux/crates/app/src/ui/browse_tab.rs | 543 ++++++------- linux/crates/app/src/ui/grid.rs | 246 +++--- linux/crates/app/src/ui/structure_tab/mod.rs | 17 +- 23 files changed, 1270 insertions(+), 1051 deletions(-) create mode 100644 linux/crates/app/src/services/cell_overlay.rs create mode 100644 linux/crates/app/src/services/change_tracker_registry.rs create mode 100644 linux/crates/app/src/services/structure_tracker_handle.rs create mode 100644 linux/crates/app/src/services/structure_tracker_registry.rs create mode 100644 linux/crates/app/src/services/tab_registry.rs create mode 100644 linux/crates/app/src/services/tab_tracker_handle.rs create mode 100644 linux/crates/app/src/services/tracker_delta.rs create mode 100644 linux/crates/app/src/services/tracker_edit.rs diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 5c7bd5797d..3b6d09bc39 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -138,16 +138,14 @@ Reopening a closed tab uses a 10-deep `VecDeque` snapshot t ## Per-tab pending-change registries -Two parallel thread-local registries hold the in-flight edit state for each tab, keyed by the same UUID as the workspace slot: +Each window owns two registries holding the in-flight edit state for its tabs, keyed by the same UUID as the workspace slot. App creates them, registers a tracker when it opens a tab, passes the handle into the tab's init, and unregisters it when the tab closes or the connection goes away: -| Registry | What it tracks | Materialised by | -|---|---|---| -| `services::change_tracker` | Row-level INSERT / UPDATE / DELETE for browse tabs | `BrowseTab::commit_save` → `Vec<(String, Vec)>` | -| `services::structure_tracker` | Column / index / FK / table-rename DDL for structure tabs | `sql_ddl::materialize_ops` → `Vec` | - -Both registries live in `thread_local!` `RefCell>` because relm4 + GTK is single-threaded on the UI side, and a single map keyed by tab UUID is simpler than passing trackers through every component handler. Helpers (`with_tab`, `with_tab_ref`, `open_tab`, `close_tab`, `any_pending_globally`) are the only public surface. +| Registry | Handle | What it tracks | Materialised by | +|---|---|---|---| +| `ChangeTrackerRegistry` | `TabTrackerHandle` | Row-level INSERT / UPDATE / DELETE for Table tabs | `TabTrackerHandle::change_set` → `TrackedChanges` | +| `StructureTrackerRegistry` | `StructureTrackerHandle` | Column / index / FK / table-rename DDL for Structure tabs | `sql_ddl::materialize_ops` → `Vec` | -A `Table` tab owns BOTH a row tracker and a DDL tracker against the same UUID. `close_workspace_tab_by_id` closes both registries; the close-with-pending dialog ORs both `has_pending()` flags and may dispatch up to two save transactions, gated through a `close_after_save: HashMap` counter. +A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to rebind, the draft rows the list store gains, loses or updates, and the new pending count. The tab applies it to its `gio::ListStore` after the call returns. `App::dirty_tabs` reads both registries for the close prompts, the teardown guard and the `•` title prefix. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. The Structure tab is **snapshot + diff**, not per-op log. `original_*` snapshots capture the load-time schema; the diff against the live model produces ops via `sql_ddl::diff_to_ops`. Discard restores the snapshot. There is no per-op undo — Discard is the only restore point. The tracker just caches the most recent diff so out-of-band callers (close prompt, save dispatcher) read the same op list without re-deriving. diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 5aa8e0614c..0460853e53 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -18,3 +18,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. +- A new row removed with Delete stayed in the grid while the save left it out. +- A save that failed on a new row did not flash that row. +- The window title showed no unsaved-changes bullet for pending structure changes. diff --git a/linux/crates/app/src/services/cell_overlay.rs b/linux/crates/app/src/services/cell_overlay.rs new file mode 100644 index 0000000000..0bc7a4def8 --- /dev/null +++ b/linux/crates/app/src/services/cell_overlay.rs @@ -0,0 +1,14 @@ +use tablepro_core::Value; + +use super::change_tracker::CellState; + +/// One cell as the grid draws it, copied out of the tracker so a bind +/// holds no borrow while it updates widgets. +#[derive(Debug, Clone, PartialEq)] +pub struct CellOverlay { + /// The pending edit when there is one, else the fetched value. + pub value: Value, + pub state: CellState, + /// The row produced the statement a save just failed on. + pub error_flash: bool, +} diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index 8c569e14b3..f2a8218646 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -1,34 +1,8 @@ -//! Per-tab pending-changeset tracker for inline spreadsheet edits. -//! -//! The tracker is the single source of truth for "what has the user -//! changed but not yet saved" in a Browse tab. It owns three buckets: -//! -//! - `inserts`: draft rows the user added (not yet persisted; PK -//! will be assigned by the database on Save for auto-increment -//! columns). -//! - `deletes`: row keys marked for deletion. Original cell values -//! are kept so the row can re-render with strikethrough styling. -//! - `updates`: per-cell modifications keyed by `(RowKey, col)`. -//! -//! `RowKey` is PK-based and stable across sort, filter, and page -//! navigation. This is the entire reason the tracker is owned by a -//! services-layer registry rather than the GTK widget tree: a -//! position-keyed identity dies on every reorder, while PK-keyed -//! identity survives. -//! -//! Threading: the tracker is **single-thread, main-thread only**. -//! All UI events run on the GTK main thread (relm4 contract), so -//! interior mutability uses `RefCell` rather than `Mutex`. The -//! registry uses `thread_local!` to avoid lock overhead in the hot -//! `connect_bind` path. -//! -//! Tests should construct a fresh `ChangeTrackerRegistry::new()` to -//! avoid contaminating each other through the thread-local global. - -use std::cell::RefCell; -use std::collections::{HashMap, VecDeque}; - -use uuid::Uuid; +//! Pending row edits for one browse tab. Rows are keyed by primary key +//! so an edit survives sort, filter and paging; the tracker is reached +//! only through `TabTrackerHandle`. + +use std::collections::{HashMap, HashSet, VecDeque}; use tablepro_core::dml::BuildSqlError; use tablepro_core::edit::CellInput; @@ -36,6 +10,10 @@ use tablepro_core::meta::RowIdentity; use tablepro_core::save::{ChangeOrigin, ChangeSet, RowInsert, RowUpdate}; use tablepro_core::{ColumnInfo, Value}; +use super::cell_overlay::CellOverlay; +use super::tracker_delta::{TrackerDelta, VisualChange}; +use super::tracker_edit::TrackerEdit; + const UNDO_LIMIT: usize = 50; /// Stable identity for a row across sort / filter / page navigation. @@ -74,7 +52,7 @@ pub type KeyValue = tablepro_core::value::ValueIdentity; /// A draft row collected by the tracker. `values` is the full column /// vector (length = `columns.len()` at the time of creation). Empty /// cells start as `Value::Null`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct DraftRow { pub draft_id: u64, pub values: Vec, @@ -88,7 +66,6 @@ pub struct CellEdit { pub new_value: Value, } -/// What the grid factory asks the tracker per cell at bind time. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CellState { Clean, @@ -96,7 +73,6 @@ pub enum CellState { InsertDraft, } -/// What the grid factory asks the tracker per row at bind time. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RowState { Clean, @@ -133,18 +109,6 @@ pub enum UndoOp { }, } -/// Event emitted to subscribers when the tracker mutates. Subscribers -/// (BrowseTab) use the row keys to call `store.items_changed(...)` for -/// affected rows so `connect_bind` re-fires with updated CSS classes. -#[derive(Debug, Clone)] -pub enum TrackerEvent { - ChangedRows(Vec), - PendingCountChanged(usize), - Cleared, -} - -/// Per-tab tracker. Created on `BrowseTab::init`, dropped when the -/// tab is closed (via `ChangeTrackerRegistry::close_tab`). #[derive(Debug, Default)] pub struct TabChangeTracker { inserts: Vec, @@ -153,47 +117,42 @@ pub struct TabChangeTracker { undo: VecDeque, redo: VecDeque, next_draft_id: u64, - subscribers: Vec>, - /// Row that produced a failing statement during the most recent - /// save. The grid bind callback consults this to apply the - /// `tp-row-leftmost-error-flash` class. Cleared by a timeout on - /// the BrowseTab side ~1.8s after the flash starts. + /// Row behind the statement the last save failed on. error_row: Option, - /// Generation counter incremented every time `set_error_row` - /// records a new failing row. The clear-timeout closure captures - /// the gen at scheduling and only clears `error_row` if the - /// counter still matches — protects against a first-flash timeout - /// blanking out a second flash that started inside its 1.8s window. + // A flash that starts inside an older flash's timeout must not be + // cleared by that older timer. error_row_gen: u64, } impl TabChangeTracker { - #[cfg(test)] - pub fn new() -> Self { - Self::default() - } - pub fn pending_count(&self) -> usize { self.inserts.len() + self.deletes.len() + self.updates.len() } - pub fn has_pending(&self) -> bool { - self.pending_count() > 0 - } - - pub fn subscribe(&mut self, sender: relm4::Sender) { - self.subscribers.push(sender); - } - - fn emit(&self, event: TrackerEvent) { - for s in &self.subscribers { - let _ = s.send(event.clone()); + pub fn apply(&mut self, edit: TrackerEdit) -> TrackerDelta { + match edit { + TrackerEdit::Cell { + row_key, + col, + original, + value, + } => self.track_cell_edit(row_key, col, original, value), + TrackerEdit::DraftCell { draft_id, col, value } => self.track_draft_cell_edit(draft_id, col, value), + TrackerEdit::Insert { values } => self.track_insert(values), + TrackerEdit::Delete { + row_key, + original_values, + } => self.track_delete(row_key, original_values), + TrackerEdit::DiscardDraft { draft_id } => self.discard_draft(draft_id), } } - fn emit_changed(&self, keys: Vec) { - self.emit(TrackerEvent::ChangedRows(keys)); - self.emit(TrackerEvent::PendingCountChanged(self.pending_count())); + fn delta(&self, rows: Vec, visual: Vec) -> TrackerDelta { + TrackerDelta { + rows, + visual, + pending_count: self.pending_count(), + } } fn push_undo(&mut self, op: UndoOp) { @@ -208,7 +167,7 @@ impl TabChangeTracker { /// updates the new value but keeps the original `prev_value` (so /// undoing restores the very-first untouched value, not the last /// intermediate state). - pub fn track_cell_edit(&mut self, row_key: RowKey, col: usize, original: Value, new: Value) { + fn track_cell_edit(&mut self, row_key: RowKey, col: usize, original: Value, new: Value) -> TrackerDelta { let key = (row_key.clone(), col); let prev_value = self .updates @@ -216,7 +175,6 @@ impl TabChangeTracker { .map(|e| e.prev_value.clone()) .unwrap_or(original.clone()); if prev_value == new { - // User reverted to the original value — drop the edit. self.updates.remove(&key); } else { self.updates.insert( @@ -233,180 +191,175 @@ impl TabChangeTracker { prev_value: original, new_value: new, }); - self.emit_changed(vec![row_key]); + self.delta(vec![row_key], Vec::new()) } - /// Append a draft row. Returns the assigned RowKey for UI to use - /// when prepending the GObject to the grid's ListStore. - pub fn track_insert(&mut self, default_values: Vec) -> RowKey { + fn track_insert(&mut self, values: Vec) -> TrackerDelta { let draft_id = self.next_draft_id; self.next_draft_id += 1; self.inserts.push(DraftRow { draft_id, - values: default_values.clone(), + values: values.clone(), }); - let key = RowKey::Draft(draft_id); self.push_undo(UndoOp::Insert { draft_id, - values: default_values, + values: values.clone(), }); - self.emit_changed(vec![key.clone()]); - key + self.delta( + vec![RowKey::Draft(draft_id)], + vec![VisualChange::InsertDraft { draft_id, values }], + ) } - /// Mark a persisted row for deletion. `original_values` is the - /// full row at the time of mark — needed both for the strikethrough - /// render and the undo path. - pub fn track_delete(&mut self, row_key: RowKey, original_values: Vec) { + /// `original_values` is the full row at the time of the mark, for + /// the strikethrough render and for undo. + fn track_delete(&mut self, row_key: RowKey, original_values: Vec) -> TrackerDelta { self.deletes.insert(row_key.clone(), original_values.clone()); self.push_undo(UndoOp::Delete { row_key: row_key.clone(), original_values, }); - self.emit_changed(vec![row_key]); + self.delta(vec![row_key], Vec::new()) } - /// Drop a draft row entirely. Mirrors what `undo` does for an - /// `UndoOp::Insert`, but reachable from the bulk-delete path - /// (Ctrl+A → Delete) so a mixed selection of drafts + persisted - /// rows behaves uniformly: persisted rows get strikethrough - /// pending-delete, drafts disappear. - /// - /// Returns `true` when a draft with `draft_id` was found and - /// removed; `false` otherwise (already discarded, or never - /// existed). Also clears any draft-cell edits and undo / redo - /// entries scoped to this draft so a subsequent Ctrl+Z doesn't - /// resurrect the draft into a half-edited state. - pub fn discard_draft(&mut self, draft_id: u64) -> bool { + // The draft's undo and redo entries go with it, so Ctrl+Z cannot + // bring it back half-edited. + fn discard_draft(&mut self, draft_id: u64) -> TrackerDelta { let before = self.inserts.len(); self.inserts.retain(|d| d.draft_id != draft_id); if self.inserts.len() == before { - return false; + return self.delta(Vec::new(), Vec::new()); } - let key = RowKey::Draft(draft_id); self.updates .retain(|(k, _), _| !matches!(k, RowKey::Draft(id) if *id == draft_id)); self.deletes .retain(|k, _| !matches!(k, RowKey::Draft(id) if *id == draft_id)); self.undo.retain(|op| !undo_op_matches_draft(op, draft_id)); self.redo.retain(|op| !undo_op_matches_draft(op, draft_id)); - self.emit_changed(vec![key]); - true + self.delta( + vec![RowKey::Draft(draft_id)], + vec![VisualChange::RemoveDraft { draft_id }], + ) } - /// Update a draft row's cell value (only valid for `RowKey::Draft`). - pub fn track_draft_cell_edit(&mut self, draft_id: u64, col: usize, new: Value) -> bool { - if let Some(draft) = self.inserts.iter_mut().find(|d| d.draft_id == draft_id) - && col < draft.values.len() - { - let prev = draft.values[col].clone(); - draft.values[col] = new.clone(); - self.push_undo(UndoOp::CellEdit { - row_key: RowKey::Draft(draft_id), - col, - prev_value: prev, - new_value: new, - }); - self.emit_changed(vec![RowKey::Draft(draft_id)]); - return true; - } - false + fn track_draft_cell_edit(&mut self, draft_id: u64, col: usize, new: Value) -> TrackerDelta { + let Some(prev) = self.draft_cell(draft_id, col) else { + return self.delta(Vec::new(), Vec::new()); + }; + let visual = self.set_draft_cell(draft_id, col, new.clone()); + self.push_undo(UndoOp::CellEdit { + row_key: RowKey::Draft(draft_id), + col, + prev_value: prev, + new_value: new, + }); + self.delta(vec![RowKey::Draft(draft_id)], visual.into_iter().collect()) } - /// Pop one entry off the undo stack, revert the tracker's - /// internal state, push it onto the redo stack, and return the - /// `UndoOp` so the UI layer knows which visual change to apply - /// (revert a RowObject's cell, remove a draft from the - /// ListStore, or just re-bind a row to drop its strikethrough). - /// - /// Returning the full op (instead of only the `RowKey`) is what - /// lets the visual revert happen: the tracker holds the - /// `prev_value` needed to restore the RowObject's cell, and the - /// caller would otherwise have to peek `self.redo.back()` after - /// the call — fragile and bypasses the encapsulation. - pub fn undo(&mut self) -> Option { + fn draft_cell(&self, draft_id: u64, col: usize) -> Option { + self.inserts + .iter() + .find(|d| d.draft_id == draft_id) + .and_then(|d| d.values.get(col)) + .cloned() + } + + fn set_draft_cell(&mut self, draft_id: u64, col: usize, value: Value) -> Option { + let cell = self + .inserts + .iter_mut() + .find(|d| d.draft_id == draft_id) + .and_then(|d| d.values.get_mut(col))?; + *cell = value.clone(); + Some(VisualChange::SetDraftCell { draft_id, col, value }) + } + + pub fn undo(&mut self) -> Option { let op = self.undo.pop_back()?; - let row_key = match &op { + let (row_key, visual) = match &op { UndoOp::CellEdit { - row_key, + row_key: RowKey::Draft(draft_id), col, prev_value, .. - } => { - if let RowKey::Draft(draft_id) = row_key { - if let Some(draft) = self.inserts.iter_mut().find(|d| d.draft_id == *draft_id) - && *col < draft.values.len() - { - draft.values[*col] = prev_value.clone(); - } - } else { - self.updates.remove(&(row_key.clone(), *col)); - } - row_key.clone() + } => ( + RowKey::Draft(*draft_id), + self.set_draft_cell(*draft_id, *col, prev_value.clone()) + .into_iter() + .collect(), + ), + UndoOp::CellEdit { row_key, col, .. } => { + self.updates.remove(&(row_key.clone(), *col)); + (row_key.clone(), Vec::new()) } UndoOp::Insert { draft_id, .. } => { self.inserts.retain(|d| d.draft_id != *draft_id); - RowKey::Draft(*draft_id) + ( + RowKey::Draft(*draft_id), + vec![VisualChange::RemoveDraft { draft_id: *draft_id }], + ) } UndoOp::Delete { row_key, .. } => { self.deletes.remove(row_key); - row_key.clone() + (row_key.clone(), Vec::new()) } }; - self.redo.push_back(op.clone()); - self.emit_changed(vec![row_key]); - Some(op) + self.redo.push_back(op); + Some(self.delta(vec![row_key], visual)) } - /// Pop one entry off the redo stack, re-apply the tracker's - /// internal state, push back onto the undo stack, and return - /// the `UndoOp` so the UI layer knows which visual change to - /// re-apply (set a RowObject's cell to `new_value`, re-add a - /// draft to the ListStore, or re-bind a row's strikethrough). - pub fn redo(&mut self) -> Option { + pub fn redo(&mut self) -> Option { let op = self.redo.pop_back()?; - let row_key = match &op { + let (row_key, visual) = match &op { + UndoOp::CellEdit { + row_key: RowKey::Draft(draft_id), + col, + new_value, + .. + } => ( + RowKey::Draft(*draft_id), + self.set_draft_cell(*draft_id, *col, new_value.clone()) + .into_iter() + .collect(), + ), UndoOp::CellEdit { row_key, col, prev_value, new_value, } => { - if let RowKey::Draft(draft_id) = row_key { - if let Some(draft) = self.inserts.iter_mut().find(|d| d.draft_id == *draft_id) - && *col < draft.values.len() - { - draft.values[*col] = new_value.clone(); - } - } else { - self.updates.insert( - (row_key.clone(), *col), - CellEdit { - prev_value: prev_value.clone(), - new_value: new_value.clone(), - }, - ); - } - row_key.clone() + self.updates.insert( + (row_key.clone(), *col), + CellEdit { + prev_value: prev_value.clone(), + new_value: new_value.clone(), + }, + ); + (row_key.clone(), Vec::new()) } UndoOp::Insert { draft_id, values } => { self.inserts.push(DraftRow { draft_id: *draft_id, values: values.clone(), }); - RowKey::Draft(*draft_id) + ( + RowKey::Draft(*draft_id), + vec![VisualChange::InsertDraft { + draft_id: *draft_id, + values: values.clone(), + }], + ) } UndoOp::Delete { row_key, original_values, } => { self.deletes.insert(row_key.clone(), original_values.clone()); - row_key.clone() + (row_key.clone(), Vec::new()) } }; - self.undo.push_back(op.clone()); - self.emit_changed(vec![row_key]); - Some(op) + self.undo.push_back(op); + Some(self.delta(vec![row_key], visual)) } #[cfg(test)] @@ -419,20 +372,42 @@ impl TabChangeTracker { !self.redo.is_empty() } - /// Drop all pending changes, undo, redo. Called by Discard and by - /// Save (after successful commit). - pub fn clear(&mut self) { + /// Drop every pending change and the undo history. + pub fn discard(&mut self) -> TrackerDelta { + let mut seen = HashSet::new(); + let rows: Vec = self + .updates + .keys() + .map(|(row_key, _)| row_key) + .chain(self.deletes.keys()) + .cloned() + .chain(self.inserts.iter().map(|draft| RowKey::Draft(draft.draft_id))) + .filter(|row_key| seen.insert(row_key.clone())) + .collect(); + let visual = self + .inserts + .iter() + .map(|draft| VisualChange::RemoveDraft { + draft_id: draft.draft_id, + }) + .collect(); self.inserts.clear(); self.deletes.clear(); self.updates.clear(); self.undo.clear(); self.redo.clear(); - self.emit(TrackerEvent::Cleared); - self.emit(TrackerEvent::PendingCountChanged(0)); + self.delta(rows, visual) + } + + pub fn cell_overlay(&self, row_key: &RowKey, col: usize, original: &Value) -> CellOverlay { + CellOverlay { + value: self.current_cell_value(row_key, col, original).clone(), + state: self.cell_state(row_key, col), + error_flash: self.error_row.as_ref() == Some(row_key), + } } - /// State for a given (row, col) cell. Used by grid `connect_bind`. - pub fn cell_state(&self, row_key: &RowKey, col: usize) -> CellState { + fn cell_state(&self, row_key: &RowKey, col: usize) -> CellState { if matches!(row_key, RowKey::Draft(_)) { return CellState::InsertDraft; } @@ -462,47 +437,28 @@ impl TabChangeTracker { &self.inserts } - /// Mark a row as the "currently failing" row for the flash animation. - /// Returns the generation counter the caller should pass to - /// `clear_error_row_if_gen` after the flash timeout, so a new flash - /// during the timeout window doesn't get blanked out by the older - /// timer firing. - pub fn set_error_row(&mut self, key: RowKey) -> u64 { + /// Mark the row a failed save points at. The returned generation + /// goes back to `clear_error_row_if` when the flash ends. + pub fn flash_error_row(&mut self, key: RowKey) -> u64 { self.error_row_gen = self.error_row_gen.wrapping_add(1); self.error_row = Some(key); self.error_row_gen } - /// Clear the error row only if the generation hasn't been bumped by - /// a newer `set_error_row` call. Used by the flash timeout to avoid - /// clearing a fresh flash that started after this timer was scheduled. - pub fn clear_error_row_if_gen(&mut self, generation: u64) { - if self.error_row_gen == generation { - self.error_row = None; + /// Clear the flash unless a newer one has started. Returns whether + /// it cleared, so the caller knows the row needs a rebind. + pub fn clear_error_row_if(&mut self, generation: u64) -> bool { + if self.error_row_gen != generation { + return false; } + self.error_row = None; + true } - /// True when the given generation matches the most recent - /// `set_error_row`. Used by the flash-clear timeout to detect - /// whether its scheduled clear is still authoritative. - pub fn is_error_row_gen(&self, generation: u64) -> bool { - self.error_row_gen == generation - } - - /// True when `key` matches the row currently flagged as the error - /// row. Used by the grid bind callback to apply the flash class. - pub fn is_error_row(&self, key: &RowKey) -> bool { - self.error_row.as_ref() == Some(key) - } - - /// Display value for a cell — pending edit if present, else - /// `original`. Used by the grid bind to render the user's pending - /// edit instead of the stale DB value. - pub fn current_cell_value<'a>(&'a self, row_key: &RowKey, col: usize, original: &'a Value) -> &'a Value { - if let Some(edit) = self.updates.get(&(row_key.clone(), col)) { - &edit.new_value - } else { - original + fn current_cell_value<'a>(&'a self, row_key: &RowKey, col: usize, original: &'a Value) -> &'a Value { + match self.updates.get(&(row_key.clone(), col)) { + Some(edit) => &edit.new_value, + None => original, } } @@ -634,82 +590,6 @@ fn undo_op_matches_draft(op: &UndoOp, draft_id: u64) -> bool { } } -/// Per-tab registry of trackers. Singleton via `thread_local!` because -/// all UI work runs on the GTK main thread. -#[derive(Debug, Default)] -pub struct ChangeTrackerRegistry { - trackers: HashMap, -} - -impl ChangeTrackerRegistry { - pub fn new() -> Self { - Self::default() - } - - pub fn open_tab(&mut self, tab_id: Uuid) { - self.trackers.entry(tab_id).or_default(); - } - - pub fn close_tab(&mut self, tab_id: Uuid) { - self.trackers.remove(&tab_id); - } - - pub fn any_pending(&self) -> bool { - self.trackers.values().any(|t| t.has_pending()) - } - - pub fn pending_tabs(&self) -> Vec { - self.trackers - .iter() - .filter(|(_, t)| t.has_pending()) - .map(|(id, _)| *id) - .collect() - } -} - -thread_local! { - static REGISTRY: RefCell = RefCell::new(ChangeTrackerRegistry::new()); -} - -/// Run a closure with mutable access to a specific tab's tracker. -/// Returns `None` if the tab has not been registered yet (e.g., -/// during early init). -pub fn with_tab(tab_id: Uuid, f: F) -> Option -where - F: FnOnce(&mut TabChangeTracker) -> R, -{ - REGISTRY.with(|reg| reg.borrow_mut().trackers.get_mut(&tab_id).map(f)) -} - -/// Run a closure with read-only access to a specific tab's tracker. -pub fn with_tab_ref(tab_id: Uuid, f: F) -> Option -where - F: FnOnce(&TabChangeTracker) -> R, -{ - REGISTRY.with(|reg| reg.borrow().trackers.get(&tab_id).map(f)) -} - -/// Open a tracker for a tab. Idempotent. -pub fn open_tab(tab_id: Uuid) { - REGISTRY.with(|reg| reg.borrow_mut().open_tab(tab_id)); -} - -/// Drop a tab's tracker. Called when the BrowseTab is closed. -pub fn close_tab(tab_id: Uuid) { - REGISTRY.with(|reg| reg.borrow_mut().close_tab(tab_id)); -} - -/// True if any open tab has pending changes — used by the app-level -/// quit guard to decide whether to show the "Unsaved changes" dialog. -pub fn any_pending_globally() -> bool { - REGISTRY.with(|reg| reg.borrow().any_pending()) -} - -/// Tabs with pending changes — ordered arbitrary (HashMap iteration). -pub fn pending_tabs() -> Vec { - REGISTRY.with(|reg| reg.borrow().pending_tabs()) -} - #[cfg(test)] mod tests { /// A column carrying the server's spelling and the kind it means, @@ -774,9 +654,16 @@ mod tests { RowKey::from_pk_values(values).unwrap() } + fn insert(t: &mut TabChangeTracker, values: Vec) -> u64 { + match t.track_insert(values).visual.as_slice() { + [VisualChange::InsertDraft { draft_id, .. }] => *draft_id, + other => panic!("an insert adds one draft row: {other:?}"), + } + } + #[test] fn cell_edit_creates_modified_state() { - let mut t = TabChangeTracker::new(); + let mut t = TabChangeTracker::default(); let key = rk(&[Value::Int(1)]); t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); assert_eq!(t.cell_state(&key, 1), CellState::Modified); @@ -786,7 +673,7 @@ mod tests { #[test] fn cell_edit_back_to_original_drops_modification() { - let mut t = TabChangeTracker::new(); + let mut t = TabChangeTracker::default(); let key = rk(&[Value::Int(1)]); t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); // User edits again, this time back to "a" @@ -799,18 +686,17 @@ mod tests { #[test] fn insert_assigns_unique_draft_ids() { - let mut t = TabChangeTracker::new(); - let k1 = t.track_insert(vec![Value::Null, Value::Null]); - let k2 = t.track_insert(vec![Value::Null, Value::Null]); + let mut t = TabChangeTracker::default(); + let k1 = insert(&mut t, vec![Value::Null, Value::Null]); + let k2 = insert(&mut t, vec![Value::Null, Value::Null]); assert_ne!(k1, k2); - assert!(matches!(k1, RowKey::Draft(_))); assert_eq!(t.drafts().len(), 2); assert_eq!(t.pending_count(), 2); } #[test] fn delete_marks_row_pending() { - let mut t = TabChangeTracker::new(); + let mut t = TabChangeTracker::default(); let key = rk(&[Value::Int(7)]); t.track_delete(key.clone(), vec![Value::Int(7), Value::Text("dave".into())]); assert_eq!(t.row_state(&key), RowState::PendingDelete); @@ -819,21 +705,18 @@ mod tests { #[test] fn undo_reverts_cell_edit() { - let mut t = TabChangeTracker::new(); + let mut t = TabChangeTracker::default(); let key = rk(&[Value::Int(1)]); t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); let undone = t.undo(); - assert!(matches!( - undone, - Some(UndoOp::CellEdit { ref row_key, col: 1, .. }) if row_key == &key - )); + assert_eq!(undone.map(|delta| delta.rows), Some(vec![key.clone()])); assert_eq!(t.cell_state(&key, 1), CellState::Clean); assert!(t.can_redo()); } #[test] fn redo_reapplies_cell_edit() { - let mut t = TabChangeTracker::new(); + let mut t = TabChangeTracker::default(); let key = rk(&[Value::Int(1)]); t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); t.undo(); @@ -843,7 +726,7 @@ mod tests { #[test] fn undo_stack_caps_at_50() { - let mut t = TabChangeTracker::new(); + let mut t = TabChangeTracker::default(); let key = rk(&[Value::Int(1)]); for i in 0..100 { t.track_cell_edit(key.clone(), 1, Value::Int(i), Value::Int(i + 1)); @@ -853,12 +736,12 @@ mod tests { #[test] fn clear_removes_all_state() { - let mut t = TabChangeTracker::new(); + let mut t = TabChangeTracker::default(); let key = rk(&[Value::Int(1)]); t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); t.track_insert(vec![Value::Null]); t.track_delete(rk(&[Value::Int(2)]), vec![Value::Int(2)]); - t.clear(); + t.discard(); assert_eq!(t.pending_count(), 0); assert!(!t.can_undo()); } @@ -878,7 +761,7 @@ mod tests { #[test] fn change_set_orders_inserts_then_updates_then_deletes() { - let mut t = TabChangeTracker::new(); + let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("name")]; let updated = rk(&[Value::Int(5)]); t.track_cell_edit(updated.clone(), 1, Value::Text("old".into()), Value::Text("new".into())); @@ -902,7 +785,7 @@ mod tests { #[test] fn a_failing_step_maps_back_to_the_row_that_made_it() { - let mut t = TabChangeTracker::new(); + let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("name")]; let doomed = rk(&[Value::Int(9)]); t.track_insert(vec![Value::Null, Value::Text("draft".into())]); @@ -917,7 +800,7 @@ mod tests { #[test] fn an_update_sets_only_the_moved_cell_and_keys_on_the_whole_primary_key() { - let mut t = TabChangeTracker::new(); + let mut t = TabChangeTracker::default(); let columns = vec![pk_col("a"), pk_col("b"), data_col("name")]; let key = RowKey::from_pk_values(&[Value::Int(1), Value::Int(2)]).unwrap(); t.track_cell_edit(key, 2, Value::Text("old".into()), Value::Text("new".into())); @@ -944,7 +827,7 @@ mod tests { reason = "a test fixture stands in for the catalogue, which is where a default expression comes from" )] fn a_draft_leaves_server_filled_columns_to_the_server() { - let mut t = TabChangeTracker::new(); + let mut t = TabChangeTracker::default(); let mut stamped = data_col("created_at"); stamped.default = tablepro_core::column::ColumnDefault::Expression( tablepro_core::column::SqlExpression::from_catalog_text("now()"), @@ -966,7 +849,7 @@ mod tests { #[test] fn a_table_without_a_primary_key_cannot_save_an_update() { - let mut t = TabChangeTracker::new(); + let mut t = TabChangeTracker::default(); let columns = vec![data_col("name")]; t.track_cell_edit( rk(&[Value::Int(1)]), @@ -978,39 +861,10 @@ mod tests { assert!(matches!(t.change_set(&columns), Err(BuildSqlError::NoRowIdentity))); } - #[test] - fn registry_open_close_idempotent() { - let mut reg = ChangeTrackerRegistry::new(); - let id = Uuid::new_v4(); - reg.open_tab(id); - reg.open_tab(id); - assert!(reg.trackers.contains_key(&id)); - reg.close_tab(id); - assert!(!reg.trackers.contains_key(&id)); - } - - #[test] - fn registry_any_pending_reports_correctly() { - let mut reg = ChangeTrackerRegistry::new(); - let id = Uuid::new_v4(); - reg.open_tab(id); - assert!(!reg.any_pending()); - let key = rk(&[Value::Int(1)]); - reg.trackers - .get_mut(&id) - .unwrap() - .track_cell_edit(key, 0, Value::Int(1), Value::Int(2)); - assert!(reg.any_pending()); - assert_eq!(reg.pending_tabs(), vec![id]); - } - #[test] fn discard_draft_removes_insert_and_clears_undo() { - let mut t = TabChangeTracker::new(); - let key = t.track_insert(vec![Value::Null, Value::Text("hi".into())]); - let RowKey::Draft(draft_id) = key else { - panic!("track_insert must return Draft key"); - }; + let mut t = TabChangeTracker::default(); + let draft_id = insert(&mut t, vec![Value::Null, Value::Text("hi".into())]); // Edit the draft so undo stack has a CellEdit on top of Insert. t.track_draft_cell_edit(draft_id, 1, Value::Text("edited".into())); assert_eq!(t.drafts().len(), 1); @@ -1018,30 +872,26 @@ mod tests { let removed = t.discard_draft(draft_id); - assert!(removed, "discard_draft must report removal"); + assert_eq!(removed.visual, vec![VisualChange::RemoveDraft { draft_id }]); assert!(t.drafts().is_empty(), "draft must be gone"); assert!(!t.can_undo(), "undo entries scoped to the draft must be cleared"); - assert!(!t.has_pending(), "no pending state after discard"); + assert_eq!(t.pending_count(), 0, "no pending state after discard"); } #[test] fn discard_draft_no_op_for_unknown_id() { - let mut t = TabChangeTracker::new(); - assert!(!t.discard_draft(99_999)); - assert!(!t.has_pending()); + let mut t = TabChangeTracker::default(); + assert_eq!(t.discard_draft(99_999), TrackerDelta::default()); + assert_eq!(t.pending_count(), 0); } #[test] fn discard_draft_leaves_other_drafts_intact() { - let mut t = TabChangeTracker::new(); - let RowKey::Draft(a) = t.track_insert(vec![Value::Int(1)]) else { - panic!() - }; - let RowKey::Draft(b) = t.track_insert(vec![Value::Int(2)]) else { - panic!() - }; + let mut t = TabChangeTracker::default(); + let a = insert(&mut t, vec![Value::Int(1)]); + let b = insert(&mut t, vec![Value::Int(2)]); assert_eq!(t.drafts().len(), 2); - assert!(t.discard_draft(a)); + assert_eq!(t.discard_draft(a).pending_count, 1); assert_eq!(t.drafts().len(), 1); assert_eq!(t.drafts()[0].draft_id, b); // The remaining draft is still undoable on its own. @@ -1054,7 +904,7 @@ mod tests { // for persisted rows): track_delete N times. Each push is its // own undoable op so Ctrl+Z restores rows one at a time — // matches the per-op undo contract elsewhere. - let mut t = TabChangeTracker::new(); + let mut t = TabChangeTracker::default(); let k1 = rk(&[Value::Int(1)]); let k2 = rk(&[Value::Int(2)]); let k3 = rk(&[Value::Int(3)]); @@ -1070,4 +920,153 @@ mod tests { assert!(matches!(t.row_state(&k1), RowState::PendingDelete)); assert!(matches!(t.row_state(&k2), RowState::PendingDelete)); } + + #[test] + fn apply_routes_each_edit_and_reports_the_rows_it_touched() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(1)]); + + let edited = t.apply(TrackerEdit::Cell { + row_key: key.clone(), + col: 1, + original: Value::Text("a".into()), + value: Value::Text("b".into()), + }); + let deleted = t.apply(TrackerEdit::Delete { + row_key: rk(&[Value::Int(2)]), + original_values: vec![Value::Int(2)], + }); + + assert_eq!(edited.rows, vec![key]); + assert!(edited.visual.is_empty()); + assert_eq!(edited.pending_count, 1); + assert_eq!(deleted.rows, vec![rk(&[Value::Int(2)])]); + assert_eq!(deleted.pending_count, 2); + } + + #[test] + fn a_draft_cell_edit_updates_the_draft_row_and_undo_restores_it() { + let mut t = TabChangeTracker::default(); + let draft_id = insert(&mut t, vec![Value::Null, Value::Text("a".into())]); + + let edited = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 1, + value: Value::Text("b".into()), + }); + let undone = t.undo(); + + assert_eq!( + edited.visual, + vec![VisualChange::SetDraftCell { + draft_id, + col: 1, + value: Value::Text("b".into()) + }] + ); + assert_eq!( + undone.map(|delta| delta.visual), + Some(vec![VisualChange::SetDraftCell { + draft_id, + col: 1, + value: Value::Text("a".into()) + }]) + ); + assert_eq!(t.drafts()[0].values[1], Value::Text("a".into())); + } + + #[test] + fn a_draft_cell_outside_the_row_changes_nothing() { + let mut t = TabChangeTracker::default(); + let draft_id = insert(&mut t, vec![Value::Null]); + + let delta = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 4, + value: Value::Int(1), + }); + + assert!(delta.rows.is_empty() && delta.visual.is_empty()); + assert_eq!(delta.pending_count, 1); + } + + #[test] + fn undo_and_redo_of_an_insert_remove_and_restore_the_draft_row() { + let mut t = TabChangeTracker::default(); + let draft_id = insert(&mut t, vec![Value::Int(7)]); + + let undone = t.undo().map(|delta| delta.visual); + let redone = t.redo().map(|delta| delta.visual); + + assert_eq!(undone, Some(vec![VisualChange::RemoveDraft { draft_id }])); + assert_eq!( + redone, + Some(vec![VisualChange::InsertDraft { + draft_id, + values: vec![Value::Int(7)] + }]) + ); + } + + #[test] + fn redo_of_a_persisted_cell_edit_leaves_the_fetched_row_alone() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(1)]); + t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); + let _ = t.undo(); + + let redone = t.redo(); + + assert_eq!( + redone.map(|delta| (delta.rows, delta.visual)), + Some((vec![key], Vec::new())) + ); + } + + #[test] + fn discard_drops_every_draft_row_and_reports_each_touched_row_once() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(1)]); + t.track_cell_edit(key.clone(), 1, Value::Int(1), Value::Int(2)); + t.track_cell_edit(key.clone(), 2, Value::Int(3), Value::Int(4)); + let draft_id = insert(&mut t, vec![Value::Null]); + + let delta = t.discard(); + + assert_eq!(delta.pending_count, 0); + assert_eq!(delta.visual, vec![VisualChange::RemoveDraft { draft_id }]); + assert_eq!(delta.rows.len(), 2, "{:?}", delta.rows); + assert!(delta.rows.contains(&key) && delta.rows.contains(&RowKey::Draft(draft_id))); + } + + #[test] + fn cell_overlay_shows_the_pending_value_over_the_fetched_one() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(1)]); + let fetched = Value::Text("a".into()); + t.track_cell_edit(key.clone(), 1, fetched.clone(), Value::Text("b".into())); + + let edited = t.cell_overlay(&key, 1, &fetched); + let untouched = t.cell_overlay(&key, 0, &Value::Int(1)); + + assert_eq!(edited.value, Value::Text("b".into())); + assert_eq!(edited.state, CellState::Modified); + assert_eq!(untouched.value, Value::Int(1)); + assert_eq!(untouched.state, CellState::Clean); + } + + #[test] + fn an_older_flash_timer_does_not_clear_a_newer_flash() { + let mut t = TabChangeTracker::default(); + let first = rk(&[Value::Int(1)]); + let second = RowKey::Draft(3); + let old = t.flash_error_row(first.clone()); + let new = t.flash_error_row(second.clone()); + + assert!(!t.clear_error_row_if(old)); + assert!(t.cell_overlay(&second, 0, &Value::Null).error_flash); + assert!(!t.cell_overlay(&first, 0, &Value::Null).error_flash); + assert!(t.clear_error_row_if(new)); + assert!(!t.cell_overlay(&second, 0, &Value::Null).error_flash); + } } diff --git a/linux/crates/app/src/services/change_tracker_registry.rs b/linux/crates/app/src/services/change_tracker_registry.rs new file mode 100644 index 0000000000..5784f2d96d --- /dev/null +++ b/linux/crates/app/src/services/change_tracker_registry.rs @@ -0,0 +1,71 @@ +use super::tab_registry::{TabRegistry, TrackerHandle}; +use super::tab_tracker_handle::TabTrackerHandle; + +pub type ChangeTrackerRegistry = TabRegistry; + +impl TrackerHandle for TabTrackerHandle { + fn has_pending(&self) -> bool { + self.pending_count() > 0 + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + use uuid::Uuid; + + use super::*; + use crate::services::change_tracker::RowKey; + use crate::services::tracker_edit::TrackerEdit; + + fn edit(tracker: &TabTrackerHandle) { + let _ = tracker.apply(TrackerEdit::Cell { + row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + col: 1, + original: Value::Int(1), + value: Value::Int(2), + }); + } + + #[test] + fn registries_isolated_per_workspace() { + let first = ChangeTrackerRegistry::default(); + let second = ChangeTrackerRegistry::default(); + let tab = Uuid::new_v4(); + + edit(&first.register(tab)); + let other = second.register(tab); + + assert_eq!(first.dirty(), vec![tab]); + assert!(second.dirty().is_empty()); + assert_eq!(other.pending_count(), 0); + } + + #[test] + fn dirty_lists_only_pending_tabs() { + let registry = ChangeTrackerRegistry::default(); + let edited = Uuid::new_v4(); + let clean = Uuid::new_v4(); + edit(®istry.register(edited)); + registry.register(clean); + + assert_eq!(registry.dirty(), vec![edited]); + + let _ = registry.get(edited).expect("registered").discard(); + assert!(registry.dirty().is_empty()); + } + + #[test] + fn register_returns_the_same_tracker_until_unregistered() { + let registry = ChangeTrackerRegistry::default(); + let tab = Uuid::new_v4(); + edit(®istry.register(tab)); + + assert_eq!(registry.register(tab).pending_count(), 1); + + registry.unregister(tab); + assert!(registry.get(tab).is_none()); + assert!(registry.dirty().is_empty()); + assert_eq!(registry.register(tab).pending_count(), 0); + } +} diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index 1f02a3f8aa..ff92e24c3f 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -3,7 +3,9 @@ use std::sync::OnceLock; use tablepro_storage::StoragePaths; pub mod active_session; +pub mod cell_overlay; pub mod change_tracker; +pub mod change_tracker_registry; pub mod history_availability; pub mod history_service; pub mod secret_labels; @@ -11,6 +13,12 @@ pub mod secret_save_report; pub mod session_context; pub mod single_instance; pub mod structure_tracker; +pub mod structure_tracker_handle; +pub mod structure_tracker_registry; +pub mod tab_registry; +pub mod tab_tracker_handle; +pub mod tracker_delta; +pub mod tracker_edit; pub mod workspace_state; static PATHS: OnceLock = OnceLock::new(); diff --git a/linux/crates/app/src/services/structure_tracker.rs b/linux/crates/app/src/services/structure_tracker.rs index c6d23b5ddb..e537729c13 100644 --- a/linux/crates/app/src/services/structure_tracker.rs +++ b/linux/crates/app/src/services/structure_tracker.rs @@ -1,32 +1,10 @@ -//! Passive per-tab DDL ops cache for the Structure workspace tab. -//! -//! StructureTab now owns the canonical source of truth for pending -//! changes — it computes them on every edit by diffing the loaded -//! snapshot against the live model (`sql_ddl::diff_to_ops`). On each -//! recompute the tab calls [`with_tab`] to overwrite this cache so -//! out-of-band callers (close-with-pending dialog, save-by-id, the -//! window-close prompt) can ask whether a tab is dirty and emit its -//! current SQL without coupling to the tab's UI internals. -//! -//! There is no undo / redo machinery here — DDL undo is a session -//! concept (Discard reverts the model to the snapshot). Per-keystroke -//! op history is the wrong granularity for schema editing and was -//! removed in the Structure-Refactor batch. -//! -//! Threading: GTK main thread only. `RefCell` interior mutability. +//! The DDL a Structure tab would run, cached so the close prompt and +//! the save dispatcher can read it without reaching into the tab. The +//! tab recomputes the ops from its snapshot diff on every edit; DDL has +//! no per-op undo, only Discard back to the snapshot. -use std::cell::RefCell; -use std::collections::HashMap; +use tablepro_core::sql_ddl::StructureOp; -use uuid::Uuid; - -use tablepro_core::dialect::SqlDialect; -use tablepro_core::sql_ddl::{BuildDdlError, StructureOp, materialize_ops}; - -/// Per-tab cache. Holds the current pending-op list emitted by the -/// tab's diff. `materialize` re-runs SQL emission on demand; the -/// cache means out-of-band callers don't have to re-derive ops from -/// the tab's model. #[derive(Debug, Default)] pub struct StructureChangeTracker { ops: Vec, @@ -41,9 +19,6 @@ impl StructureChangeTracker { &self.ops } - /// Replace the cached op list. Called by StructureTab on every - /// model mutation; the tab is the source of truth for what ops - /// the diff currently produces. pub fn set_ops(&mut self, ops: Vec) { self.ops = ops; } @@ -51,87 +26,6 @@ impl StructureChangeTracker { pub fn clear(&mut self) { self.ops.clear(); } - - /// The SQL for the cached ops in `dialect`, which has to be the - /// connection's own: renames and default drops are spelled per - /// server. - pub fn materialize(&self, driver_id: &str, dialect: &dyn SqlDialect) -> Result, BuildDdlError> { - materialize_ops(&self.ops, driver_id, dialect) - } -} - -#[derive(Debug, Default)] -pub struct StructureTrackerRegistry { - trackers: HashMap, -} - -impl StructureTrackerRegistry { - pub fn open_tab(&mut self, tab_id: Uuid) { - self.trackers.entry(tab_id).or_default(); - } - - pub fn close_tab(&mut self, tab_id: Uuid) { - self.trackers.remove(&tab_id); - } - - pub fn with_tab(&mut self, tab_id: Uuid, f: F) -> R - where - F: FnOnce(&mut StructureChangeTracker) -> R, - { - f(self.trackers.entry(tab_id).or_default()) - } - - pub fn with_tab_ref(&self, tab_id: Uuid, f: F) -> Option - where - F: FnOnce(&StructureChangeTracker) -> R, - { - self.trackers.get(&tab_id).map(f) - } - - pub fn any_pending_globally(&self) -> bool { - self.trackers.values().any(|t| t.has_pending()) - } - - pub fn pending_tabs(&self) -> Vec { - self.trackers - .iter() - .filter_map(|(id, t)| if t.has_pending() { Some(*id) } else { None }) - .collect() - } -} - -thread_local! { - static REGISTRY: RefCell = RefCell::new(StructureTrackerRegistry::default()); -} - -pub fn open_tab(tab_id: Uuid) { - REGISTRY.with(|r| r.borrow_mut().open_tab(tab_id)); -} - -pub fn close_tab(tab_id: Uuid) { - REGISTRY.with(|r| r.borrow_mut().close_tab(tab_id)); -} - -pub fn with_tab(tab_id: Uuid, f: F) -> R -where - F: FnOnce(&mut StructureChangeTracker) -> R, -{ - REGISTRY.with(|r| r.borrow_mut().with_tab(tab_id, f)) -} - -pub fn with_tab_ref(tab_id: Uuid, f: F) -> Option -where - F: FnOnce(&StructureChangeTracker) -> R, -{ - REGISTRY.with(|r| r.borrow().with_tab_ref(tab_id, f)) -} - -pub fn any_pending_globally() -> bool { - REGISTRY.with(|r| r.borrow().any_pending_globally()) -} - -pub fn pending_tabs() -> Vec { - REGISTRY.with(|r| r.borrow().pending_tabs()) } #[cfg(test)] @@ -172,41 +66,4 @@ mod tests { t.set_ops(vec![]); assert!(!t.has_pending()); } - - #[test] - fn materialize_emits_sql() { - let mut t = StructureChangeTracker::default(); - t.set_ops(vec![StructureOp::AddColumn { - schema: None, - table: "t".into(), - column: dc("a", "int"), - }]); - let sql = t - .materialize( - "postgres", - &drivers_postgres::PgDialect::new(&tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)), - ) - .unwrap(); - assert_eq!(sql.len(), 1); - assert!(sql[0].contains("ALTER TABLE")); - } - - #[test] - fn registry_isolates_per_tab() { - let mut r = StructureTrackerRegistry::default(); - let a = Uuid::new_v4(); - let b = Uuid::new_v4(); - r.with_tab(a, |t| { - t.set_ops(vec![StructureOp::AddColumn { - schema: None, - table: "t".into(), - column: dc("a", "int"), - }]) - }); - assert!(r.with_tab_ref(a, |t| t.has_pending()).unwrap()); - assert!(!r.with_tab_ref(b, |t| t.has_pending()).unwrap_or(false)); - assert_eq!(r.pending_tabs(), vec![a]); - r.close_tab(a); - assert!(!r.any_pending_globally()); - } } diff --git a/linux/crates/app/src/services/structure_tracker_handle.rs b/linux/crates/app/src/services/structure_tracker_handle.rs new file mode 100644 index 0000000000..eab37083f9 --- /dev/null +++ b/linux/crates/app/src/services/structure_tracker_handle.rs @@ -0,0 +1,29 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use tablepro_core::sql_ddl::StructureOp; + +use super::structure_tracker::StructureChangeTracker; + +/// A Structure tab's cached DDL. Every method borrows only for its own +/// body and returns owned data. +#[derive(Debug, Clone, Default)] +pub struct StructureTrackerHandle(Rc>); + +impl StructureTrackerHandle { + pub fn set_ops(&self, ops: Vec) { + self.0.borrow_mut().set_ops(ops); + } + + pub fn ops(&self) -> Vec { + self.0.borrow().ops().to_vec() + } + + pub fn has_pending(&self) -> bool { + self.0.borrow().has_pending() + } + + pub fn clear(&self) { + self.0.borrow_mut().clear(); + } +} diff --git a/linux/crates/app/src/services/structure_tracker_registry.rs b/linux/crates/app/src/services/structure_tracker_registry.rs new file mode 100644 index 0000000000..45b9122ab4 --- /dev/null +++ b/linux/crates/app/src/services/structure_tracker_registry.rs @@ -0,0 +1,61 @@ +use super::structure_tracker_handle::StructureTrackerHandle; +use super::tab_registry::{TabRegistry, TrackerHandle}; + +pub type StructureTrackerRegistry = TabRegistry; + +impl TrackerHandle for StructureTrackerHandle { + fn has_pending(&self) -> bool { + StructureTrackerHandle::has_pending(self) + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::sql_ddl::{DraftColumn, StructureOp}; + use uuid::Uuid; + + use super::*; + + fn add_column() -> StructureOp { + StructureOp::AddColumn { + schema: None, + table: "t".into(), + column: DraftColumn { + original: None, + name: "a".into(), + data_type: "int".into(), + nullable: true, + primary_key: false, + auto_increment: false, + default_value: None, + comment: None, + }, + } + } + + #[test] + fn registries_isolated_per_workspace() { + let first = StructureTrackerRegistry::default(); + let second = StructureTrackerRegistry::default(); + let tab = Uuid::new_v4(); + + first.register(tab).set_ops(vec![add_column()]); + second.register(tab); + + assert_eq!(first.dirty(), vec![tab]); + assert!(second.dirty().is_empty()); + } + + #[test] + fn dirty_lists_only_pending_tabs() { + let registry = StructureTrackerRegistry::default(); + let edited = Uuid::new_v4(); + registry.register(edited).set_ops(vec![add_column()]); + registry.register(Uuid::new_v4()); + + assert_eq!(registry.dirty(), vec![edited]); + + registry.get(edited).expect("registered").clear(); + assert!(registry.dirty().is_empty()); + } +} diff --git a/linux/crates/app/src/services/tab_registry.rs b/linux/crates/app/src/services/tab_registry.rs new file mode 100644 index 0000000000..26c2642b75 --- /dev/null +++ b/linux/crates/app/src/services/tab_registry.rs @@ -0,0 +1,52 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use uuid::Uuid; + +/// A tab's tracker as a workspace registry holds it. +pub trait TrackerHandle: Clone + Default { + fn has_pending(&self) -> bool; +} + +/// One workspace's trackers, keyed by tab. Each window owns its own, so +/// edits in one window never count as unsaved in another. +#[derive(Debug)] +pub struct TabRegistry { + trackers: RefCell>, +} + +impl Default for TabRegistry { + fn default() -> Self { + Self { + trackers: RefCell::new(HashMap::new()), + } + } +} + +impl TabRegistry { + /// The tab's tracker, created on first use. + pub fn register(&self, tab_id: Uuid) -> H { + self.trackers.borrow_mut().entry(tab_id).or_default().clone() + } + + pub fn unregister(&self, tab_id: Uuid) { + self.trackers.borrow_mut().remove(&tab_id); + } + + pub fn get(&self, tab_id: Uuid) -> Option { + self.trackers.borrow().get(&tab_id).cloned() + } + + /// Tabs with unsaved changes, in a stable order. + pub fn dirty(&self) -> Vec { + let mut dirty: Vec = self + .trackers + .borrow() + .iter() + .filter(|(_, tracker)| tracker.has_pending()) + .map(|(tab_id, _)| *tab_id) + .collect(); + dirty.sort_unstable(); + dirty + } +} diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs new file mode 100644 index 0000000000..b30b133d5d --- /dev/null +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -0,0 +1,132 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use tablepro_core::dml::BuildSqlError; +use tablepro_core::{ColumnInfo, Value}; + +use super::cell_overlay::CellOverlay; +use super::change_tracker::{DraftRow, RowKey, RowState, TabChangeTracker, TrackedChanges}; +use super::tracker_delta::TrackerDelta; +use super::tracker_edit::TrackerEdit; + +/// A browse tab's tracker. Each method borrows only for its own body +/// and hands back owned data, so a grid bind that runs while the +/// caller is still applying a delta never meets a held borrow. +#[derive(Debug, Clone, Default)] +pub struct TabTrackerHandle(Rc>); + +impl TabTrackerHandle { + pub fn cell_overlay(&self, row_key: &RowKey, col: usize, original: &Value) -> CellOverlay { + self.0.borrow().cell_overlay(row_key, col, original) + } + + pub fn row_state(&self, row_key: &RowKey) -> RowState { + self.0.borrow().row_state(row_key) + } + + #[must_use = "the grid shows stale rows until the delta is applied"] + pub fn apply(&self, edit: TrackerEdit) -> TrackerDelta { + self.0.borrow_mut().apply(edit) + } + + #[must_use = "the grid shows stale rows until the delta is applied"] + pub fn undo(&self) -> Option { + self.0.borrow_mut().undo() + } + + #[must_use = "the grid shows stale rows until the delta is applied"] + pub fn redo(&self) -> Option { + self.0.borrow_mut().redo() + } + + pub fn pending_count(&self) -> usize { + self.0.borrow().pending_count() + } + + pub fn drafts(&self) -> Vec { + self.0.borrow().drafts().to_vec() + } + + pub fn change_set(&self, columns: &[ColumnInfo]) -> Result { + self.0.borrow().change_set(columns) + } + + #[must_use = "the grid shows stale rows until the delta is applied"] + pub fn discard(&self) -> TrackerDelta { + self.0.borrow_mut().discard() + } + + pub fn flash_error_row(&self, row_key: RowKey) -> u64 { + self.0.borrow_mut().flash_error_row(row_key) + } + + pub fn clear_error_row_if(&self, generation: u64) -> bool { + self.0.borrow_mut().clear_error_row_if(generation) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::tracker_delta::VisualChange; + + fn row(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") + } + + #[test] + fn apply_returns_delta_with_pending_count() { + let tracker = TabTrackerHandle::default(); + + let edited = tracker.apply(TrackerEdit::Cell { + row_key: row(1), + col: 1, + original: Value::Text("a".into()), + value: Value::Text("b".into()), + }); + let inserted = tracker.apply(TrackerEdit::Insert { + values: vec![Value::Null, Value::Text("c".into())], + }); + + assert_eq!(edited.rows, vec![row(1)]); + assert_eq!(edited.pending_count, 1); + assert_eq!(inserted.pending_count, 2); + assert!(matches!( + inserted.visual.as_slice(), + [VisualChange::InsertDraft { values, .. }] if values[1] == Value::Text("c".into()) + )); + assert_eq!(tracker.pending_count(), 2); + } + + #[test] + fn clones_share_one_tracker() { + let tracker = TabTrackerHandle::default(); + let grid_copy = tracker.clone(); + + let _ = tracker.apply(TrackerEdit::Delete { + row_key: row(4), + original_values: vec![Value::Int(4)], + }); + + assert_eq!(grid_copy.row_state(&row(4)), RowState::PendingDelete); + } + + #[test] + fn a_read_inside_a_delta_loop_finds_no_held_borrow() { + let tracker = TabTrackerHandle::default(); + let delta = tracker.apply(TrackerEdit::Cell { + row_key: row(1), + col: 0, + original: Value::Int(1), + value: Value::Int(2), + }); + + for key in &delta.rows { + let _ = tracker.apply(TrackerEdit::Delete { + row_key: key.clone(), + original_values: vec![Value::Int(1)], + }); + assert_eq!(tracker.cell_overlay(key, 0, &Value::Int(1)).value, Value::Int(2)); + } + } +} diff --git a/linux/crates/app/src/services/tracker_delta.rs b/linux/crates/app/src/services/tracker_delta.rs new file mode 100644 index 0000000000..0e13a043ad --- /dev/null +++ b/linux/crates/app/src/services/tracker_delta.rs @@ -0,0 +1,22 @@ +use tablepro_core::Value; + +use super::change_tracker::RowKey; + +/// What a tracker change means for the grid, handed back once the +/// tracker is no longer borrowed so the caller can touch GTK freely. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TrackerDelta { + /// Rows whose pending state changed and need a rebind. + pub rows: Vec, + /// Draft rows the list store has to gain, lose or update, since a + /// draft's cells live on its row object. + pub visual: Vec, + pub pending_count: usize, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum VisualChange { + SetDraftCell { draft_id: u64, col: usize, value: Value }, + RemoveDraft { draft_id: u64 }, + InsertDraft { draft_id: u64, values: Vec }, +} diff --git a/linux/crates/app/src/services/tracker_edit.rs b/linux/crates/app/src/services/tracker_edit.rs new file mode 100644 index 0000000000..d8eca5ddd1 --- /dev/null +++ b/linux/crates/app/src/services/tracker_edit.rs @@ -0,0 +1,29 @@ +use tablepro_core::Value; + +use super::change_tracker::RowKey; + +/// One change a browse tab records against its pending edits. +#[derive(Debug, Clone, PartialEq)] +pub enum TrackerEdit { + Cell { + row_key: RowKey, + col: usize, + original: Value, + value: Value, + }, + DraftCell { + draft_id: u64, + col: usize, + value: Value, + }, + Insert { + values: Vec, + }, + Delete { + row_key: RowKey, + original_values: Vec, + }, + DiscardDraft { + draft_id: u64, + }, +} diff --git a/linux/crates/app/src/ui/app/browse.rs b/linux/crates/app/src/ui/app/browse.rs index 3b6e5deee7..0ba5bf1c1e 100644 --- a/linux/crates/app/src/ui/app/browse.rs +++ b/linux/crates/app/src/ui/app/browse.rs @@ -200,7 +200,10 @@ impl App { let Some(id) = self.selected_browse_tab_id() else { return; }; - let dirty = crate::services::change_tracker::with_tab_ref(id, |tr| tr.has_pending()).unwrap_or(false); + let dirty = self + .change_trackers + .get(id) + .is_some_and(|tracker| tracker.pending_count() > 0); if !dirty { self.dispatch_to_tab(id, BrowseTabInput::Refresh); return; @@ -223,11 +226,10 @@ impl App { let workspace_tabs = self.workspace_tabs.clone(); dialog.connect_response(None, move |dlg: &adw::AlertDialog, response: &str| { dlg.close(); - if response == "discard" { - crate::services::change_tracker::with_tab(id, |t| t.clear()); - if let Some(controller) = workspace_tabs.borrow().get(&id).and_then(|t| t.browse_controller()) { - let _ = controller.sender().send(BrowseTabInput::Refresh); - } + if response == "discard" + && let Some(controller) = workspace_tabs.borrow().get(&id).and_then(|t| t.browse_controller()) + { + let _ = controller.sender().send(BrowseTabInput::DiscardAndRefresh); } }); dialog.present(Some(&self.window)); diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index d5eb2e0c55..abcb1acca7 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -911,7 +911,7 @@ impl App { // title with "• " when any open document has unsaved changes, // so the dirty state is visible from the Activities overview / // Alt-Tab without needing the tab to be focused. - if crate::services::change_tracker::any_pending_globally() { + if !self.dirty_tabs().is_empty() { os_title = format!("• {os_title}"); } self.window.set_title(Some(&os_title)); diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 0221d66c43..54c59ff9f8 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -29,7 +29,9 @@ use super::editor::{SqlEditor, build_schema_buffer}; use super::history_dialog::HistoryDialog; use super::sidebar_row::{SidebarRow, SidebarRowOutput}; use super::welcome_view::{WelcomeView, WelcomeViewInit, WelcomeViewOutput}; +use crate::services::change_tracker_registry::ChangeTrackerRegistry; use crate::services::session_context::SessionContext; +use crate::services::structure_tracker_registry::StructureTrackerRegistry; pub(crate) use connection::{ConnectRetry, ReadySession}; @@ -119,6 +121,10 @@ pub struct App { /// HashMap for O(1) tab_id lookup; display order is read from /// `tab_view.pages()` since HashMap is unordered. workspace_tabs: std::rc::Rc>>, + /// This window's pending row edits, one tracker per Table tab. + change_trackers: std::rc::Rc, + /// This window's pending DDL, one tracker per Structure tab. + structure_trackers: std::rc::Rc, dialog: Option, schema_buffer: gtk::TextBuffer, history_dialog: Option>, @@ -634,7 +640,20 @@ impl StatusKind { } } +/// Tabs in either registry with unsaved changes. +fn dirty_tabs(changes: &ChangeTrackerRegistry, structures: &StructureTrackerRegistry) -> Vec { + let mut dirty = changes.dirty(); + dirty.extend(structures.dirty()); + dirty.sort_unstable(); + dirty.dedup(); + dirty +} + impl App { + pub(super) fn dirty_tabs(&self) -> Vec { + dirty_tabs(&self.change_trackers, &self.structure_trackers) + } + /// The active driver id, or "postgres" if no connection is active. /// /// Single fallback site (was duplicated at 7 call sites). The @@ -851,6 +870,10 @@ impl SimpleComponent for App { let close_window_after_save_handle = close_window_after_save_for_close.clone(); let in_flight_saves_handle = in_flight_saves.clone(); let in_flight_saves_for_close = in_flight_saves.clone(); + let change_trackers = std::rc::Rc::new(ChangeTrackerRegistry::default()); + let structure_trackers = std::rc::Rc::new(StructureTrackerRegistry::default()); + let change_trackers_for_close = change_trackers.clone(); + let structure_trackers_for_close = structure_trackers.clone(); let close_request_input_sender = sender.input_sender().clone(); widgets.window.connect_close_request(move |w| { // If a Save is mid-flight (async transaction running), block @@ -887,8 +910,7 @@ impl SimpleComponent for App { // below) — skip the guard, save state, allow close. // Browse + Structure tabs share the dirty-state guard: // either source of pending changes triggers the dialog. - let has_pending = crate::services::change_tracker::any_pending_globally() - || crate::services::structure_tracker::any_pending_globally(); + let has_pending = !dirty_tabs(&change_trackers_for_close, &structure_trackers_for_close).is_empty(); if !force_close_for_close.get() && has_pending { // Plural-form heading matches the per-tab dialog's // tone — factual GNOME HIG language rather than the @@ -913,15 +935,23 @@ impl SimpleComponent for App { let close_after_save_for_resp = close_after_save_for_close.clone(); let close_window_after_save_for_resp = close_window_after_save_for_close.clone(); let input_sender_for_resp = close_request_input_sender.clone(); + let change_trackers_for_resp = change_trackers_for_close.clone(); + let structure_trackers_for_resp = structure_trackers_for_close.clone(); dialog.connect_response(None, move |dlg, response| { dlg.close(); match response { "discard" => { - for tab_id in crate::services::change_tracker::pending_tabs() { - crate::services::change_tracker::with_tab(tab_id, |t| t.clear()); + // The window closes next, so no grid is left + // to show the discarded rows. + for tab_id in change_trackers_for_resp.dirty() { + if let Some(tracker) = change_trackers_for_resp.get(tab_id) { + let _ = tracker.discard(); + } } - for tab_id in crate::services::structure_tracker::pending_tabs() { - crate::services::structure_tracker::with_tab(tab_id, |t| t.clear()); + for tab_id in structure_trackers_for_resp.dirty() { + if let Some(tracker) = structure_trackers_for_resp.get(tab_id) { + tracker.clear(); + } } force_close_for_resp.set(true); // Re-fire close_request — guard sees the flag, @@ -936,8 +966,8 @@ impl SimpleComponent for App { // the SaveCompletedForTab / StructureSaveCompleted // handlers in App::update close the window once // the set drains. Any SaveFailed aborts. - let browse_tabs: Vec = crate::services::change_tracker::pending_tabs(); - let structure_tabs: Vec = crate::services::structure_tracker::pending_tabs(); + let browse_tabs = change_trackers_for_resp.dirty(); + let structure_tabs = structure_trackers_for_resp.dirty(); // Counter increments — a Table tab listed in both // sets bumps to 2 so the window close waits for // BOTH the browse save and the structure save. @@ -1311,6 +1341,8 @@ impl SimpleComponent for App { workspace_tab_view: None, workspace_root_added: std::cell::Cell::new(false), workspace_tabs: std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashMap::new())), + change_trackers, + structure_trackers, dialog: None, schema_buffer: build_schema_buffer(), history_dialog: None, diff --git a/linux/crates/app/src/ui/app/row_ops.rs b/linux/crates/app/src/ui/app/row_ops.rs index 941171ac80..4b009346fa 100644 --- a/linux/crates/app/src/ui/app/row_ops.rs +++ b/linux/crates/app/src/ui/app/row_ops.rs @@ -186,7 +186,9 @@ mod tests { use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType, IntegerKind, IntegerWidth}; use super::*; - use crate::services::change_tracker::{RowKey, TabChangeTracker}; + use crate::services::change_tracker::RowKey; + use crate::services::tab_tracker_handle::TabTrackerHandle; + use crate::services::tracker_edit::TrackerEdit; fn column(name: &str, primary_key: bool) -> ColumnInfo { let mut column_type = ColumnType::untyped_text("integer"); @@ -209,9 +211,16 @@ mod tests { fn planned() -> (SavePlan, TrackedChanges, RowKey) { let columns = vec![column("id", true), column("n", false)]; let edited = RowKey::from_pk_values(&[Value::Int(3)]).unwrap(); - let mut tracker = TabChangeTracker::new(); - tracker.track_insert(vec![Value::Int(10), Value::Int(1)]); - tracker.track_cell_edit(edited.clone(), 1, Value::Int(1), Value::Int(2)); + let tracker = TabTrackerHandle::default(); + let _ = tracker.apply(TrackerEdit::Insert { + values: vec![Value::Int(10), Value::Int(1)], + }); + let _ = tracker.apply(TrackerEdit::Cell { + row_key: edited.clone(), + col: 1, + original: Value::Int(1), + value: Value::Int(2), + }); let changes = tracker.change_set(&columns).unwrap(); let dialect = drivers_sqlite::SqliteDialect::new(drivers_sqlite::SqliteVersion::new(3, 45, 0)); let plan = plan_save( diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index 753ee2954c..31e1efe83d 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -23,7 +23,6 @@ use tablepro_core::{ForeignKeyMode, WriteBatch, WriteKind, WriteReport}; use tablepro_session::runtime::TaskFailure; use tablepro_session::{OperationError, OperationKind, Session}; -use crate::services::structure_tracker; use crate::ui::app::{App, AppMsg, WorkspaceTab}; use crate::ui::error_text; use crate::ui::structure_tab::{StructureMode, StructureTabInput}; @@ -180,24 +179,20 @@ impl App { )); return; }; - let driver_id = self.driver_id().to_string(); - let result = structure_tracker::with_tab_ref(tab_id, |t| t.materialize(&driver_id, &*dialect)); - match result { - Some(Ok(statements)) if !statements.is_empty() => { + let Some(tracker) = self.structure_trackers.get(tab_id) else { + return; + }; + match materialize_ops(&tracker.ops(), self.driver_id(), &*dialect) { + Ok(statements) if !statements.is_empty() => { self.on_execute_structure_transaction(tab_id, statements, sender); } - Some(Ok(_)) => { - // Nothing to save — short-circuit so close-after-save - // can proceed. - sender.input(AppMsg::StructureSaveCompleted { - tab_id, - new_table_name: None, - }); - } - Some(Err(e)) => { - sender.input(AppMsg::StructureSaveFailed(tab_id, format!("{e}"))); - } - None => {} + // Nothing to save, so a close waiting on this save can go + // ahead. + Ok(_) => sender.input(AppMsg::StructureSaveCompleted { + tab_id, + new_table_name: None, + }), + Err(e) => sender.input(AppMsg::StructureSaveFailed(tab_id, format!("{e}"))), } } @@ -276,7 +271,11 @@ impl App { } } }; - let ops = structure_tracker::with_tab_ref(tab_id, |t| t.ops().to_vec()).unwrap_or_default(); + let ops = self + .structure_trackers + .get(tab_id) + .map(|tracker| tracker.ops()) + .unwrap_or_default(); let new_table_name = saved_table_name(mode, &ops); // The batch names the table as it is before the write: a new // table by the name it is being created under. @@ -604,7 +603,10 @@ impl App { // Combine with the data-side dirty state — either // mode contributing pending changes prefixes the tab // with the "•" GNOME convention. - let data = crate::services::change_tracker::with_tab_ref(s.id, |tr| tr.has_pending()).unwrap_or(false); + let data = self + .change_trackers + .get(s.id) + .is_some_and(|tracker| tracker.pending_count() > 0); (&s.page, s.schema.as_deref(), s.table.clone(), dirty || data) } _ => return, diff --git a/linux/crates/app/src/ui/app/teardown_guard.rs b/linux/crates/app/src/ui/app/teardown_guard.rs index 941cfb0fc9..a83f17b8c3 100644 --- a/linux/crates/app/src/ui/app/teardown_guard.rs +++ b/linux/crates/app/src/ui/app/teardown_guard.rs @@ -3,8 +3,6 @@ use std::cell::Cell; use relm4::adw::prelude::*; use relm4::{ComponentSender, adw}; -use crate::services::{change_tracker, structure_tracker}; - use super::{App, AppMsg, WorkspaceTab}; /// What closing the workspace would throw away. @@ -41,7 +39,7 @@ pub(super) enum Teardown { impl App { pub(super) fn workspace_loss(&self) -> WorkspaceLoss { - let unsaved_edits = change_tracker::any_pending_globally() || structure_tracker::any_pending_globally(); + let unsaved_edits = !self.dirty_tabs().is_empty(); let open_transactions = self .workspace_tabs .borrow() diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index a6ac0f80ab..1f08564629 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -340,7 +340,7 @@ impl App { let tab_id = Uuid::new_v4(); let driver_id = self.driver_id().to_string(); let init = crate::ui::structure_tab::StructureTabInit { - tab_id, + tracker: self.structure_trackers.register(tab_id), schema: schema.clone(), table: String::new(), mode: crate::ui::structure_tab::StructureMode::New, @@ -418,7 +418,7 @@ impl App { let read_only = self.read_only; let browse_init = BrowseTabInit { - tab_id, + tracker: self.change_trackers.register(tab_id), schema: schema.clone(), table: table.clone(), connection_id, @@ -500,7 +500,7 @@ impl App { let driver_id = self.driver_id().to_string(); let mode = crate::ui::structure_tab::StructureMode::Edit; let init = crate::ui::structure_tab::StructureTabInit { - tab_id, + tracker: self.structure_trackers.register(tab_id), schema: schema.clone(), table: table.clone(), mode, @@ -621,28 +621,7 @@ impl App { return; }; - // If this is a Browse tab with pending changeset, intercept the - // close with an AdwAlertDialog (Discard / Cancel). The user can - // also cancel close, save manually, then close — no Save-and- - // close branch keeps the async commit logic out of this path. - let pending = self - .workspace_tabs - .borrow() - .get(&id) - .and_then(|t| match t { - WorkspaceTab::Structure(s) => { - crate::services::structure_tracker::with_tab_ref(s.id, |tr| tr.has_pending()) - } - WorkspaceTab::Table(s) => { - let data_dirty = - crate::services::change_tracker::with_tab_ref(s.id, |tr| tr.has_pending()).unwrap_or(false); - let struct_dirty = - crate::services::structure_tracker::with_tab_ref(s.id, |tr| tr.has_pending()).unwrap_or(false); - Some(data_dirty || struct_dirty) - } - _ => None, - }) - .unwrap_or(false); + let pending = self.dirty_tabs().contains(&id); let open_transaction = matches!( self.workspace_tabs.borrow().get(&id), Some(WorkspaceTab::Editor(slot)) if slot.editor.has_open_transaction() @@ -692,18 +671,23 @@ impl App { // branches so a Table tab whose only dirt is on the // Structure side doesn't try to save through the Browse // path (and vice versa). - let data_dirty = crate::services::change_tracker::with_tab_ref(id, |tr| tr.has_pending()).unwrap_or(false); - let struct_dirty = - crate::services::structure_tracker::with_tab_ref(id, |tr| tr.has_pending()).unwrap_or(false); + let data_tracker = self + .change_trackers + .get(id) + .filter(|tracker| tracker.pending_count() > 0); + let structure_tracker = self.structure_trackers.get(id).filter(|tracker| tracker.has_pending()); + let (data_dirty, struct_dirty) = (data_tracker.is_some(), structure_tracker.is_some()); dialog.connect_response(None, move |dlg, response| { dlg.close(); match response { "discard" => { - if data_dirty { - crate::services::change_tracker::with_tab(id, |t| t.clear()); + // The tab closes next, so no grid is left to + // show the discarded rows. + if let Some(tracker) = &data_tracker { + let _ = tracker.discard(); } - if struct_dirty { - crate::services::structure_tracker::with_tab(id, |t| t.clear()); + if let Some(tracker) = &structure_tracker { + tracker.clear(); } sender_for_resp.input(AppMsg::WorkspaceTabClosed(id)); } @@ -810,18 +794,11 @@ impl App { .discard(&tablepro_storage::DraftScope::default(), slot.draft); } WorkspaceTab::Structure(slot) => { - crate::services::structure_tracker::close_tab(slot.id); + self.structure_trackers.unregister(slot.id); self.structure_saves_in_flight.borrow_mut().remove(&slot.id); } WorkspaceTab::Table(slot) => { - // A Table tab owns BOTH a Browse-side row tracker and a - // Structure-side DDL tracker against the same uuid; close - // both registries. Also drop the in-flight-save guard - // entry so a re-opened tab with the same uuid (or a - // dropped async future from `drop_on_shutdown`) doesn't - // leave the save path permanently locked. - crate::services::change_tracker::close_tab(slot.id); - crate::services::structure_tracker::close_tab(slot.id); + self.change_trackers.unregister(slot.id); self.structure_saves_in_flight.borrow_mut().remove(&slot.id); } } @@ -1177,9 +1154,7 @@ impl App { return; }; let (page, schema, table) = (&s.page, s.schema.as_deref(), s.table.as_str()); - let data = crate::services::change_tracker::with_tab_ref(s.id, |tr| tr.has_pending()).unwrap_or(false); - let structure = crate::services::structure_tracker::with_tab_ref(s.id, |tr| tr.has_pending()).unwrap_or(false); - let combined_dirty = data || structure || dirty; + let combined_dirty = dirty || self.dirty_tabs().contains(&s.id); let base = qualified_browse_tab_label(schemas_count, schema, table); let title = if combined_dirty { format!("• {base}") } else { base }; page.set_title(&title); @@ -1198,16 +1173,12 @@ impl App { // teardown drops state before the 500ms timer would fire. self.do_persist_workspace_state_now(); self.cancel_all_editor_runs(); - // Drop per-tab pending-change trackers — disconnecting wipes - // the connection and its row identities, so any pending edits - // would no longer be commitable. + // The pending edits name rows of the connection going away, so + // they cannot be saved after it. for tab in self.workspace_tabs.borrow().values() { match tab { - WorkspaceTab::Structure(slot) => crate::services::structure_tracker::close_tab(slot.id), - WorkspaceTab::Table(slot) => { - crate::services::change_tracker::close_tab(slot.id); - crate::services::structure_tracker::close_tab(slot.id); - } + WorkspaceTab::Structure(slot) => self.structure_trackers.unregister(slot.id), + WorkspaceTab::Table(slot) => self.change_trackers.unregister(slot.id), WorkspaceTab::Editor(_) => {} } } diff --git a/linux/crates/app/src/ui/browse_tab.rs b/linux/crates/app/src/ui/browse_tab.rs index 8efbc107ae..5508117a2d 100644 --- a/linux/crates/app/src/ui/browse_tab.rs +++ b/linux/crates/app/src/ui/browse_tab.rs @@ -10,6 +10,10 @@ use tablepro_core::edit::{CellInput, EditContext, EditParseError}; use tablepro_core::{ColumnInfo, QueryResult, Value}; use super::grid::{CellPreset, GridMsg, TabGridContext, build_column_view}; +use crate::services::change_tracker::{RowKey, StatementSource, TrackedChanges}; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::services::tracker_delta::{TrackerDelta, VisualChange}; +use crate::services::tracker_edit::TrackerEdit; const PAGE_SIZE_OPTIONS: &[u64] = &[100, 500, 1_000, 5_000, 10_000]; const DEFAULT_PAGE_SIZE: u64 = 1_000; @@ -22,7 +26,7 @@ const DEFAULT_PAGE_SIZE: u64 = 1_000; const BULK_DELETE_CONFIRM_THRESHOLD: usize = 10; pub struct BrowseTabInit { - pub tab_id: Uuid, + pub tracker: TabTrackerHandle, pub schema: Option, pub table: String, pub connection_id: Option, @@ -35,7 +39,7 @@ pub struct BrowseTabInit { } pub struct BrowseTab { - tab_id: Uuid, + tracker: TabTrackerHandle, schema: Option, table: String, connection_id: Option, @@ -86,7 +90,7 @@ pub struct BrowseTab { /// once the new page lands. Persisted rows match by PK; drafts /// match by draft_id (for the rare case where a draft is focused /// when a sort happens). Cleared by `restore_focused_row`. - pending_focus_restore: std::cell::RefCell>, + pending_focus_restore: std::cell::RefCell>, paginator_label: gtk::Label, /// Live count of selected rows. Hidden when 0 or 1 rows are /// selected; shows "{n} selected" once the user shift-clicks @@ -224,17 +228,9 @@ pub enum BrowseTabInput { row_position: u32, }, GridCopyToClipboard(String), - /// Ctrl+Z on this tab. Pops one entry off the change tracker's - /// undo stack AND mirrors the visual revert in the grid: - /// CellEdit → reset the RowObject's cell + items_changed; - /// Insert → remove the draft RowObject from the ListStore; - /// Delete → re-bind the row so the strikethrough overlay drops. - /// Without the mirror the chrome (counter, .tp-cell-modified - /// class) updated correctly while the cell text stayed at the - /// post-edit value. + /// Ctrl+Z on this tab. Undo, - /// Ctrl+Shift+Z. Symmetric to Undo: re-applies the popped op - /// and mirrors the visual change forward. + /// Ctrl+Shift+Z. Redo, /// User clicked Save: hand the tracker's changes to the App as one /// `BrowseTabOutput::ExecuteTransaction`, which saves them as one @@ -243,14 +239,14 @@ pub enum BrowseTabInput { /// User clicked Discard — clear all pending edits and refetch /// the page so the grid shows committed values again. DiscardAll, - /// Pending count changed (from tracker subscription) — refresh - /// the Save / Discard / counter visibility. - PendingCountChanged(usize), - /// Specific rows mutated in the tracker (cell edit, set NULL, - /// insert, delete, undo, redo). Triggers a targeted re-bind of - /// just those rows so pending-state CSS classes update without - /// re-binding the entire visible viewport. - ChangedRows(Vec), + /// Drop every pending edit, then reload the page and count. + DiscardAndRefresh, + /// Mark persisted rows for deletion and drop drafts, once a bulk + /// delete is confirmed. + MarkRowsDeleted { + rows: Vec<(RowKey, Vec)>, + drafts: Vec, + }, /// Save command resolved successfully — clear tracker, refetch. SaveCompleted, /// Save command failed — surface error to the user; keep the @@ -262,7 +258,7 @@ pub enum BrowseTabInput { /// and deletes) and scroll-and-select it. Best-effort: if the row /// isn't on the current page (paginated past it, sorted away), /// the alert dialog still tells the user which statement failed. - FlashErrorRow(crate::services::change_tracker::StatementSource), + FlashErrorRow(StatementSource), /// Ctrl+C with row(s) selected: serialize each selected row as /// tab-separated cells and push to the system clipboard. Falls /// through to GTK's default Ctrl+C if no rows are selected so @@ -322,7 +318,7 @@ pub enum BrowseTabOutput { ExecuteTransaction { table: tablepro_core::meta::TableRef, columns: Vec, - changes: crate::services::change_tracker::TrackedChanges, + changes: TrackedChanges, }, } @@ -351,7 +347,7 @@ impl BrowseTab { /// is looking at, pending edits included. fn grid_context(&self) -> TabGridContext { TabGridContext { - tab_id: Some(self.tab_id), + tracker: Some(self.tracker.clone()), column_widths: self .connection_id .map(|connection_id| crate::ui::grid::ColumnWidthBinding { @@ -699,6 +695,71 @@ impl BrowseTab { self.refresh_banner_visibility(); } + /// Apply what a tracker call changed: the draft rows the store gains + /// or loses, then a rebind of each touched row, then the footer. + fn apply_tracker_delta(&self, delta: TrackerDelta, sender: &ComponentSender) { + if let Some(store) = self.list_store() { + for change in &delta.visual { + apply_visual_change(&store, change); + } + } + for key in &delta.rows { + if let Some(position) = self.find_row_position_by_key(key) { + self.refresh_row_at(position); + } + } + self.sync_pending_chrome(delta.pending_count, sender); + } + + /// The footer and the tab's dirty flag, which App turns into the + /// title bullet. App hears only real transitions. + fn sync_pending_chrome(&self, count: usize, sender: &ComponentSender) { + self.refresh_pending_bar(count); + let dirty = count > 0; + if dirty != self.was_dirty.replace(dirty) { + let _ = sender.output(BrowseTabOutput::DirtyChanged(dirty)); + } + } + + fn apply_edit(&self, edit: TrackerEdit, sender: &ComponentSender) { + let delta = self.tracker.apply(edit); + self.apply_tracker_delta(delta, sender); + } + + /// A typed or preset value for one cell. A draft keeps the value on + /// its own row; a persisted row keeps it in the tracker. + fn edit_cell(&self, row_position: u32, col: usize, value: Value, sender: &ComponentSender) { + if let Some(draft_id) = self.row_object_at(row_position).and_then(|row| row.draft_id()) { + self.apply_edit(TrackerEdit::DraftCell { draft_id, col, value }, sender); + return; + } + let Some((row_key, row)) = self.row_key_at(row_position) else { + return; + }; + let Some(original) = row.get(col).cloned() else { + return; + }; + self.apply_edit( + TrackerEdit::Cell { + row_key, + col, + original, + value, + }, + sender, + ); + } + + fn reload(&self, sender: &ComponentSender) { + self.capture_focus_for_restore(); + self.show_loading_inner( + &crate::i18n::gettext("Loading…"), + &crate::i18n::gettext_f("Fetching rows from {table}", &[("table", &self.table_label())]), + ); + let _ = sender.output(BrowseTabOutput::FetchPage); + let _ = sender.output(BrowseTabOutput::FetchRowCount); + } + /// Reveal at most ONE banner at a time. Read-only takes priority /// over no-PK because read-only blocks every kind of edit. fn refresh_banner_visibility(&self) { @@ -775,8 +836,8 @@ impl BrowseTab { /// recomputing the row's PK key-tuple from cell values and /// comparing. Returns the position in the (filtered) selection /// model, or `None` if the row isn't on the current page. - fn find_row_position_by_key(&self, key: &crate::services::change_tracker::RowKey) -> Option { - use crate::services::change_tracker::{KeyValue, RowKey}; + fn find_row_position_by_key(&self, key: &RowKey) -> Option { + use crate::services::change_tracker::KeyValue; let selection = self.current_selection.as_ref()?; let model = selection.model()?; let pk_indices: Vec = self @@ -813,16 +874,10 @@ impl BrowseTab { None } - /// Locate the row that produced a failing statement and scroll-and- - /// select it, plus apply a one-shot red flash animation. The flash - /// state lives on `TabChangeTracker.error_row` so the grid bind - /// callback picks it up via the existing tracker query path; a - /// 1.8s timeout clears the state afterwards (matches the CSS - /// animation duration). Best-effort lookup: a row that's been - /// paginated past, sorted away, or filtered out won't be found, - /// and we silently fall through to just the alert dialog. - fn flash_error_row(&self, source: &crate::services::change_tracker::StatementSource) { - use crate::services::change_tracker::{RowKey, StatementSource}; + /// Scroll to the row behind a failed statement and flash it. A row + /// paged, sorted or filtered away is not found, and the alert alone + /// tells the user which statement failed. + fn flash_error_row(&self, source: &StatementSource) { let key = match source { StatementSource::Insert { draft_id } => RowKey::Draft(*draft_id), StatementSource::Update { row_key } | StatementSource::Delete { row_key } => row_key.clone(), @@ -841,31 +896,15 @@ impl BrowseTab { None, ); } - // Mark the row as the error row and trigger a re-bind so the - // bind callback applies tp-row-leftmost-error-flash. Schedule - // a timeout to clear the state once the animation has played. - // The generation counter protects against a second flash that - // starts inside the 1.8s window: the older timer's clear runs - // but no-ops because gen no longer matches. - let tab_id = self.tab_id; - let generation = - crate::services::change_tracker::with_tab(tab_id, |t| t.set_error_row(key.clone())).unwrap_or(0); + let generation = self.tracker.flash_error_row(key); self.refresh_row_at(position); + let tracker = self.tracker.clone(); let selection_for_clear = self.current_selection.clone(); + // Matches the length of the flash animation in the stylesheet. glib::timeout_add_local_once(std::time::Duration::from_millis(1800), move || { - let cleared = crate::services::change_tracker::with_tab(tab_id, |t| { - let was_match = t.is_error_row_gen(generation); - t.clear_error_row_if_gen(generation); - was_match - }) - .unwrap_or(false); - if !cleared { - // A newer flash superseded ours; don't disturb its bind. + if !tracker.clear_error_row_if(generation) { return; } - // Inline the underlying-store hop because `self` is gone - // from the closure scope (it's a 1.8s deferred timer); - // the chain walk is the same as `refresh_row_at`. if let Some(selection) = selection_for_clear && let Some(model) = selection.model() && let Some(row_obj) = model @@ -884,7 +923,6 @@ impl BrowseTab { /// (sort flip, save, F5 refresh) so we can re-anchor the user's /// view to the same row after the new page renders. fn capture_focus_for_restore(&self) { - use crate::services::change_tracker::RowKey; // If a previous capture is still pending (sort+save fire in // quick succession before the first reload's restore runs), keep // the original capture. The first user-visible focus should @@ -1153,16 +1191,11 @@ impl BrowseTab { let Some(store) = self.list_store() else { return; }; - let drafts = crate::services::change_tracker::with_tab_ref(self.tab_id, |t| { - t.drafts() - .iter() - .map(|d| (d.draft_id, d.values.clone())) - .collect::>() - }) - .unwrap_or_default(); - for (draft_id, values) in drafts { - let draft_row = super::row_object::RowObject::new_draft(draft_id, values); - store.insert(0, &draft_row); + for draft in self.tracker.drafts() { + store.insert( + 0, + &super::row_object::RowObject::new_draft(draft.draft_id, draft.values), + ); } } @@ -1188,7 +1221,7 @@ impl BrowseTab { /// Returns None for drafts (caller is expected to handle the /// draft path before reaching here), for tables with no PK, or /// when the position is out of range. - fn row_key_at(&self, row_position: u32) -> Option<(crate::services::change_tracker::RowKey, Vec)> { + fn row_key_at(&self, row_position: u32) -> Option<(RowKey, Vec)> { let row_obj = self.row_object_at(row_position)?; if row_obj.draft_id().is_some() { return None; @@ -1357,11 +1390,6 @@ impl SimpleComponent for BrowseTab { } fn init(init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { - // Open this tab's pending-changeset tracker. Closed in - // workspace_tabs::close_workspace_tab_by_id when the tab is - // removed. Idempotent — calling twice is a no-op. - crate::services::change_tracker::open_tab(init.tab_id); - // Restore the saved filter for this (connection, schema, // table) up front so both the model field and the inline // strip start with the same FilterSet. @@ -1717,7 +1745,7 @@ impl SimpleComponent for BrowseTab { })); let model = BrowseTab { - tab_id: init.tab_id, + tracker: init.tracker, schema: init.schema, table: init.table, connection_id: init.connection_id, @@ -1765,25 +1793,6 @@ impl SimpleComponent for BrowseTab { // the moment the tab opens. model.refresh_filter_chrome(); - // Subscribe to the tracker so we can refresh the pending UI - // any time the user adds / undoes / commits a change. The - // channel is leaked into the GTK main loop via spawn_future_local, - // matching how the per-tab GridMsg channel above is wired. - let (tracker_sender, tracker_receiver) = relm4::channel::(); - crate::services::change_tracker::with_tab(init.tab_id, |t| t.subscribe(tracker_sender)); - let input_for_tracker = sender.input_sender().clone(); - glib::spawn_future_local(tracker_receiver.forward(input_for_tracker, move |event| match event { - crate::services::change_tracker::TrackerEvent::PendingCountChanged(n) => { - BrowseTabInput::PendingCountChanged(n) - } - crate::services::change_tracker::TrackerEvent::Cleared => BrowseTabInput::PendingCountChanged(0), - // ChangedRows drives targeted items_changed so only the - // affected rows re-bind, not the whole visible viewport. - // PendingCountChanged is emitted alongside by the tracker - // (see emit_changed) so banner / dirty-flag updates still - // run for the same mutation. - crate::services::change_tracker::TrackerEvent::ChangedRows(keys) => BrowseTabInput::ChangedRows(keys), - })); // Trigger the initial fetches the moment the parent attaches us. // The parent's forward closure wraps these in AppMsg::… with tab_id. let _ = sender.output(BrowseTabOutput::FetchColumns); @@ -1875,14 +1884,11 @@ impl SimpleComponent for BrowseTab { self.show_error_inner(&message); self.inner_stack.set_visible_child_name("error"); } - BrowseTabInput::Refresh => { - self.capture_focus_for_restore(); - self.show_loading_inner( - &crate::i18n::gettext("Loading…"), - &crate::i18n::gettext_f("Fetching rows from {table}", &[("table", &self.table_label())]), - ); - let _ = sender.output(BrowseTabOutput::FetchPage); - let _ = sender.output(BrowseTabOutput::FetchRowCount); + BrowseTabInput::Refresh => self.reload(&sender), + BrowseTabInput::DiscardAndRefresh => { + let delta = self.tracker.discard(); + self.apply_tracker_delta(delta, &sender); + self.reload(&sender); } BrowseTabInput::ClearSelection => { let Some(sel) = self.current_selection.as_ref() else { @@ -2020,18 +2026,7 @@ impl SimpleComponent for BrowseTab { } }) .collect(); - let key_opt = - crate::services::change_tracker::with_tab(self.tab_id, |t| t.track_insert(values.clone())); - let Some(key) = key_opt else { - return; - }; - let crate::services::change_tracker::RowKey::Draft(draft_id) = key else { - return; - }; - if let Some(store) = self.list_store() { - let draft_row = super::row_object::RowObject::new_draft(draft_id, values); - store.insert(0, &draft_row); - } + self.apply_edit(TrackerEdit::Insert { values }, &sender); self.refresh_inner_stack_visibility(); // Scroll + focus deferred via FocusInsertedDraft — see // the matching note in the `InsertRow` arm above. Same @@ -2043,25 +2038,8 @@ impl SimpleComponent for BrowseTab { if self.current_columns.is_empty() { return; } - // Inline draft: track in the changeset (returns a - // RowKey::Draft(N) handle), then prepend a fresh - // RowObject tagged with the same draft id to the - // grid's ListStore. The new row appears at the top - // with a green tint and editable cells; user fills - // them inline and clicks Save to commit. - let default_values: Vec = self.current_columns.iter().map(|_| Value::Null).collect(); - let key_opt = - crate::services::change_tracker::with_tab(self.tab_id, |t| t.track_insert(default_values.clone())); - let Some(key) = key_opt else { - return; - }; - let crate::services::change_tracker::RowKey::Draft(draft_id) = key else { - return; - }; - if let Some(store) = self.list_store() { - let draft_row = super::row_object::RowObject::new_draft(draft_id, default_values); - store.insert(0, &draft_row); - } + let values: Vec = self.current_columns.iter().map(|_| Value::Null).collect(); + self.apply_edit(TrackerEdit::Insert { values }, &sender); // The empty-state status page hides the column view — // if we were sitting on it (zero persisted rows on the // first page) the draft we just appended would be @@ -2112,7 +2090,7 @@ impl SimpleComponent for BrowseTab { let Some(model) = selection.model() else { return; }; - let mut snapshot: Vec<(crate::services::change_tracker::RowKey, Vec)> = Vec::new(); + let mut snapshot: Vec<(RowKey, Vec)> = Vec::new(); let mut draft_ids: Vec = Vec::new(); let mut had_persisted_row = false; for pos in &positions { @@ -2143,26 +2121,9 @@ impl SimpleComponent for BrowseTab { return; } let count = snapshot.len() + draft_ids.len(); - let tab_id = self.tab_id; - let selection_for_commit = selection.clone(); - let commit_delete = move |snapshot: Vec<(crate::services::change_tracker::RowKey, Vec)>, - draft_ids: Vec| { - crate::services::change_tracker::with_tab(tab_id, |t| { - for (key, row) in snapshot { - t.track_delete(key, row); - } - for id in draft_ids { - t.discard_draft(id); - } - }); - // Clear the multi-row selection so the - // "{n} selected" badge disappears and the Delete - // button tooltip resets. Without this the bitset - // still contains the now-strikethrough rows - // (they remain in the model until Save). Spreadsheet - // convention: a bulk action ends the selection it - // operated on. - selection_for_commit.unselect_all(); + let sender_for_commit = sender.clone(); + let commit_delete = move |rows: Vec<(RowKey, Vec)>, drafts: Vec| { + sender_for_commit.input(BrowseTabInput::MarkRowsDeleted { rows, drafts }); }; if count >= BULK_DELETE_CONFIRM_THRESHOLD { // Dialog parent is any descendant of the toplevel @@ -2245,27 +2206,7 @@ impl SimpleComponent for BrowseTab { return; } }; - let row_obj = self.row_object_at(row_position); - if let Some(row_obj) = &row_obj - && let Some(draft_id) = row_obj.draft_id() - { - // Draft row — mutate the tracker's draft buffer - // directly. The RowObject's own cells are also - // updated so the grid's display reflects the - // pending value without waiting for re-fetch. - crate::services::change_tracker::with_tab(self.tab_id, |t| { - t.track_draft_cell_edit(draft_id, col_index, new.clone()); - }); - row_obj.set_cell(col_index, new); - return; - } - let Some((key, row)) = self.row_key_at(row_position) else { - return; - }; - let original = row[col_index].clone(); - crate::services::change_tracker::with_tab(self.tab_id, |t| { - t.track_cell_edit(key, col_index, original, new); - }); + self.edit_cell(row_position, col_index, new, &sender); } BrowseTabInput::GridSetCellValue { row_position, @@ -2279,22 +2220,7 @@ impl SimpleComponent for BrowseTab { return; } }; - if let Some(row_obj) = self.row_object_at(row_position) - && let Some(draft_id) = row_obj.draft_id() - { - crate::services::change_tracker::with_tab(self.tab_id, |t| { - t.track_draft_cell_edit(draft_id, col_index, value.clone()); - }); - row_obj.set_cell(col_index, value); - return; - } - let Some((key, row)) = self.row_key_at(row_position) else { - return; - }; - let original = row[col_index].clone(); - crate::services::change_tracker::with_tab(self.tab_id, |t| { - t.track_cell_edit(key, col_index, original, value); - }); + self.edit_cell(row_position, col_index, value, &sender); } BrowseTabInput::GridShowToast(message) => { let _ = sender.output(BrowseTabOutput::ShowToast(message)); @@ -2323,12 +2249,16 @@ impl SimpleComponent for BrowseTab { }); } BrowseTabInput::GridDeleteRowAt { row_position } => { - let Some((key, row)) = self.row_key_at(row_position) else { + let Some((row_key, original_values)) = self.row_key_at(row_position) else { return; }; - crate::services::change_tracker::with_tab(self.tab_id, |t| { - t.track_delete(key, row); - }); + self.apply_edit( + TrackerEdit::Delete { + row_key, + original_values, + }, + &sender, + ); } BrowseTabInput::GridCopyRowAsInsert { row_position } => { let _ = sender.output(BrowseTabOutput::CopyRowAsInsert { row_position }); @@ -2417,9 +2347,8 @@ impl SimpleComponent for BrowseTab { } BrowseTabInput::CommitSave => { let columns = self.current_columns.clone(); - let result = crate::services::change_tracker::with_tab_ref(self.tab_id, |t| t.change_set(&columns)); - match result { - Some(Ok(changes)) if !changes.changes.is_empty() => { + match self.tracker.change_set(&columns) { + Ok(changes) if !changes.changes.is_empty() => { // Disable both buttons for the duration of the // in-flight transaction. SaveCompleted / // SaveFailed re-enable them. This prevents a @@ -2438,55 +2367,44 @@ impl SimpleComponent for BrowseTab { changes, }); } - Some(Ok(_)) => { - // Nothing to save (tracker empty) — refresh bar. - self.refresh_pending_bar(0); - } - Some(Err(e)) => { + Ok(_) => self.refresh_pending_bar(0), + Err(e) => { let _ = sender.output(BrowseTabOutput::ShowSelectionAlert { title: crate::i18n::gettext("Cannot save"), body: format!("{e}"), }); } - None => {} } } BrowseTabInput::DiscardAll => { - crate::services::change_tracker::with_tab(self.tab_id, |t| t.clear()); + let delta = self.tracker.discard(); + self.apply_tracker_delta(delta, &sender); let _ = sender.output(BrowseTabOutput::FetchPage); } - BrowseTabInput::PendingCountChanged(n) => { - self.refresh_pending_bar(n); - // Tell the App so it can prefix the tab title with the - // GNOME-Text-Editor "•" dot for dirty buffers. Only on - // real transitions (empty ↔ non-empty) so a count - // change like 2 → 3 doesn't re-rewrite the tab title. - let dirty = n > 0; - if dirty != self.was_dirty.get() { - self.was_dirty.set(dirty); - let _ = sender.output(BrowseTabOutput::DirtyChanged(dirty)); + BrowseTabInput::MarkRowsDeleted { rows, drafts } => { + for (row_key, original_values) in rows { + self.apply_edit( + TrackerEdit::Delete { + row_key, + original_values, + }, + &sender, + ); } - // Note: row re-binds are driven by the parallel - // ChangedRows event, not from here. PendingCountChanged - // fires on every tracker mutation so re-binding the - // viewport here would be wasteful — most edits affect - // exactly one row and ChangedRows hits only that row. - } - BrowseTabInput::ChangedRows(keys) => { - // Walk the model once per key. For typical interactive - // edits (one cell at a time) this is O(n) per keystroke - // where n = visible row count — bounded and cheap. - // Bulk operations (Discard) emit one ChangedRows per - // op via undo unwind, again bounded. - for key in &keys { - if let Some(pos) = self.find_row_position_by_key(key) { - self.refresh_row_at(pos); - } + for draft_id in drafts { + self.apply_edit(TrackerEdit::DiscardDraft { draft_id }, &sender); + } + // A bulk action ends the selection it acted on. + if let Some(selection) = self.current_selection.as_ref() { + selection.unselect_all(); } } BrowseTabInput::SaveCompleted => { - crate::services::change_tracker::with_tab(self.tab_id, |t| t.clear()); - self.refresh_pending_bar(0); + // The reload replaces every row; dropping the saved + // drafts from the store first would blank them until it + // lands. + let delta = self.tracker.discard(); + self.sync_pending_chrome(delta.pending_count, &sender); self.save_button.set_sensitive(true); self.discard_button.set_sensitive(true); let _ = sender.output(BrowseTabOutput::FetchPage); @@ -2507,102 +2425,44 @@ impl SimpleComponent for BrowseTab { self.focus_inserted_draft(); } BrowseTabInput::Undo => { - use crate::services::change_tracker::UndoOp; - let op = match crate::services::change_tracker::with_tab(self.tab_id, |t| t.undo()) { - Some(Some(op)) => op, - _ => return, - }; - match op { - UndoOp::CellEdit { - row_key, - col, - prev_value, - .. - } => { - // Drafts hold the post-edit value in - // RowObject.cells (mirrored at edit time); - // reverting the visible value requires - // mutating the cell back. Persisted rows - // never mutate RowObject — the tracker is - // the single source of truth and - // connect_bind queries - // `current_cell_value` to overlay the - // pending edit. set_cell on a persisted - // row is a harmless no-op (the cell - // already holds the original). - if let Some(pos) = self.find_row_position_by_key(&row_key) - && let Some(row_obj) = self.row_object_at(pos) - { - row_obj.set_cell(col, prev_value); - } - } - UndoOp::Insert { draft_id, .. } => { - // The draft RowObject is still in the - // ListStore (prepended by InsertRow). Walk - // the store, find the row whose draft_id - // matches, remove it. The subsequent - // ChangedRows event for `Draft(id)` is a - // no-op once the row is gone. - if let Some(store) = self.list_store() { - let n = store.n_items(); - for i in 0..n { - if let Some(obj) = store.item(i) - && let Ok(row) = obj.downcast::() - && row.draft_id() == Some(draft_id) - { - store.remove(i); - break; - } - } - } - } - UndoOp::Delete { row_key, .. } => { - // No RowObject mutation needed — the row was - // never visually removed; it stayed in the - // ListStore with a strikethrough overlay - // applied at bind time. The tracker's - // emit_changed → items_changed re-bind drops - // the strikethrough automatically because - // `row_state` returns Clean once the deletes - // entry is gone. - let _ = row_key; - } + if let Some(delta) = self.tracker.undo() { + self.apply_tracker_delta(delta, &sender); } } BrowseTabInput::Redo => { - use crate::services::change_tracker::UndoOp; - let op = match crate::services::change_tracker::with_tab(self.tab_id, |t| t.redo()) { - Some(Some(op)) => op, - _ => return, - }; - match op { - UndoOp::CellEdit { - row_key, - col, - new_value, - .. - } => { - if let Some(pos) = self.find_row_position_by_key(&row_key) - && let Some(row_obj) = self.row_object_at(pos) - { - row_obj.set_cell(col, new_value); - } - } - UndoOp::Insert { draft_id, values } => { - // Re-add the draft RowObject. Match the - // original insert path: prepend at position 0. - if let Some(store) = self.list_store() { - let draft_row = super::row_object::RowObject::new_draft(draft_id, values); - store.insert(0, &draft_row); - } - } - UndoOp::Delete { .. } => {} + if let Some(delta) = self.tracker.redo() { + self.apply_tracker_delta(delta, &sender); } } } } } +fn apply_visual_change(store: >k::gio::ListStore, change: &VisualChange) { + use super::row_object::RowObject; + let draft_at = |draft_id: u64| { + (0..store.n_items()).find_map(|position| { + let row = store.item(position).and_downcast::()?; + (row.draft_id() == Some(draft_id)).then_some((position, row)) + }) + }; + match change { + VisualChange::InsertDraft { draft_id, values } => { + store.insert(0, &RowObject::new_draft(*draft_id, values.clone())); + } + VisualChange::RemoveDraft { draft_id } => { + if let Some((position, _)) = draft_at(*draft_id) { + store.remove(position); + } + } + VisualChange::SetDraftCell { draft_id, col, value } => { + if let Some((_, row)) = draft_at(*draft_id) { + row.set_cell(*col, value.clone()); + } + } + } +} + fn clear_box(b: >k::Box) { while let Some(child) = b.first_child() { b.remove(&child); @@ -2633,15 +2493,12 @@ fn normalize_single_line_input(text: &str) -> String { /// / RowObject. Callers feed it a clone of the row's cells (already /// pulled from the model in selection-model space, see the /// row-position-vs-result-rows note in `DeleteSelectedRow`). -fn build_persisted_row_key( - cells: Vec, - pk_indices: &[usize], -) -> Option<(crate::services::change_tracker::RowKey, Vec)> { +fn build_persisted_row_key(cells: Vec, pk_indices: &[usize]) -> Option<(RowKey, Vec)> { let pk_values: Vec = pk_indices .iter() .map(|&i| cells.get(i).cloned()) .collect::>()?; - let key = crate::services::change_tracker::RowKey::from_pk_values(&pk_values)?; + let key = RowKey::from_pk_values(&pk_values)?; Some((key, cells)) } @@ -2735,8 +2592,12 @@ fn result_columns(columns: &[tablepro_core::ColumnInfo]) -> std::sync::Arc<[tabl #[cfg(test)] mod tests { - use super::{build_persisted_row_key, format_thousands, parse_input_for_column}; + use gtk4::prelude::*; + + use super::{apply_visual_change, build_persisted_row_key, format_thousands, parse_input_for_column}; use crate::services::change_tracker::RowKey; + use crate::services::tracker_delta::VisualChange; + use crate::ui::row_object::RowObject; use tablepro_core::edit::EditContext; use tablepro_core::{ColumnInfo, Value}; @@ -3011,4 +2872,36 @@ mod tests { let (key, _) = build_persisted_row_key(cells, &[0]).expect("Null PK is valid"); assert!(matches!(key, RowKey::Persisted(_))); } + + fn row_at(store: >k4::gio::ListStore, position: u32) -> RowObject { + store.item(position).and_downcast::().expect("a row") + } + + #[test] + fn visual_changes_add_update_and_remove_draft_rows() { + let store = gtk4::gio::ListStore::new::(); + store.append(&RowObject::new(vec![Value::Int(1)])); + + apply_visual_change( + &store, + &VisualChange::InsertDraft { + draft_id: 4, + values: vec![Value::Null], + }, + ); + apply_visual_change( + &store, + &VisualChange::SetDraftCell { + draft_id: 4, + col: 0, + value: Value::Int(9), + }, + ); + assert_eq!(row_at(&store, 0).draft_id(), Some(4)); + assert_eq!(row_at(&store, 0).cell_value(0), Value::Int(9)); + + apply_visual_change(&store, &VisualChange::RemoveDraft { draft_id: 4 }); + assert_eq!(store.n_items(), 1); + assert_eq!(row_at(&store, 0).draft_id(), None); + } } diff --git a/linux/crates/app/src/ui/grid.rs b/linux/crates/app/src/ui/grid.rs index 75366366d8..b06d4b68f7 100644 --- a/linux/crates/app/src/ui/grid.rs +++ b/linux/crates/app/src/ui/grid.rs @@ -12,6 +12,9 @@ use tablepro_core::column::ResultColumn; use tablepro_core::{ColumnInfo, QueryResult, Value}; use super::row_object::RowObject; +use crate::services::cell_overlay::CellOverlay; +use crate::services::change_tracker::{CellState, RowKey, RowState}; +use crate::services::tab_tracker_handle::TabTrackerHandle; #[derive(Debug)] pub enum GridMsg { @@ -71,13 +74,11 @@ pub enum CellPreset { Null, } -/// Per-tab context plumbed into the grid factory so cell bind-time -/// callbacks can query the change tracker for pending-state CSS -/// classes. `tab_id == None` means the grid is read-only / not -/// associated with a tracked Browse tab (e.g., editor results). +/// What a browse grid draws pending edits from. `tracker` is `None` for +/// a grid nothing tracks, such as editor results. #[derive(Debug, Clone, Default)] pub struct TabGridContext { - pub tab_id: Option, + pub tracker: Option, pub pk_col_indices: Vec, /// Where a dragged column edge is remembered. `None` for a grid /// with nothing to remember it against, such as editor results. @@ -119,31 +120,18 @@ impl ColumnWidthBinding { } impl TabGridContext { - /// The tracker key for a persisted row. `None` when the grid isn't - /// backed by a tracked tab, when the row is a draft (its own cells - /// are the tracker's mirror and already authoritative), or when - /// the row carries no usable primary key. - fn tracked_key(&self, row: &RowObject) -> Option<(uuid::Uuid, crate::services::change_tracker::RowKey)> { - let tab_id = self.tab_id?; - if row.draft_id().is_some() { - return None; - } - let pk_values: Vec = self.pk_col_indices.iter().map(|&i| row.cell_value(i)).collect(); - let key = crate::services::change_tracker::RowKey::from_pk_values(&pk_values)?; - Some((tab_id, key)) - } - - /// What the grid is showing for one cell: the tracker's pending - /// edit when there is one, else the row's stored value. - fn effective_cell(&self, row: &RowObject, idx: usize) -> Value { - let raw = row.cell_value(idx); - let Some((tab_id, key)) = self.tracked_key(row) else { - return raw; + /// The tracker and the row's key in it. `None` when nothing tracks + /// the grid or a persisted row has no usable primary key. + fn tracked_key(&self, row: &RowObject) -> Option<(&TabTrackerHandle, RowKey)> { + let tracker = self.tracker.as_ref()?; + let key = match row.draft_id() { + Some(draft_id) => RowKey::Draft(draft_id), + None => { + let pk_values: Vec = self.pk_col_indices.iter().map(|&i| row.cell_value(i)).collect(); + RowKey::from_pk_values(&pk_values)? + } }; - match crate::services::change_tracker::with_tab_ref(tab_id, |t| t.current_cell_value(&key, idx, &raw).clone()) { - Some(value) => value, - None => raw, - } + Some((tracker, key)) } /// The whole row as the grid is showing it. Every copy and export @@ -151,19 +139,41 @@ impl TabGridContext { /// a persisted row holds the untouched values the fetch returned. pub(super) fn effective_cells(&self, row: &RowObject) -> Vec { let raw = row.cells_clone(); - let Some((tab_id, key)) = self.tracked_key(row) else { + let Some((tracker, key)) = self.tracked_key(row) else { return raw; }; - match crate::services::change_tracker::with_tab_ref(tab_id, |t| { - raw.iter() - .enumerate() - .map(|(i, v)| t.current_cell_value(&key, i, v).clone()) - .collect::>() - }) { - Some(cells) => cells, - None => raw, - } + raw.iter() + .enumerate() + .map(|(col, value)| tracker.cell_overlay(&key, col, value).value) + .collect() + } + + /// The value a bind draws and the pending-state classes it carries. + fn bind_state(&self, row: &RowObject, col: usize) -> (Value, Vec<&'static str>) { + let raw = row.cell_value(col); + let Some((tracker, key)) = self.tracked_key(row) else { + return (raw, Vec::new()); + }; + let overlay = tracker.cell_overlay(&key, col, &raw); + let classes = pending_classes(tracker.row_state(&key), &overlay, col); + (overlay.value, classes) + } +} + +/// Pending state shows as the row's tint alone: insert green, modified +/// orange, delete red with a strikethrough. +fn pending_classes(row_state: RowState, overlay: &CellOverlay, col: usize) -> Vec<&'static str> { + let mut classes = Vec::new(); + match (row_state, overlay.state) { + (RowState::PendingDelete, _) => classes.push("tp-row-pending-delete"), + (RowState::InsertDraft, _) => classes.push("tp-row-pending-insert"), + (_, CellState::Modified) => classes.push("tp-cell-modified"), + _ => {} + } + if col == 0 && overlay.error_flash { + classes.push("tp-row-leftmost-error-flash"); } + classes } pub fn build_column_view( @@ -377,20 +387,7 @@ fn build_column( let Some(row) = item.item().and_downcast::() else { return; }; - // For persisted rows, RowObject.cells holds the immutable - // original DB values — the tracker is the single source of - // truth for pending edits. Override with the tracker's - // pending value when one exists so the cell visibly - // reflects what the user typed (with `tp-cell-modified` - // applied below for the orange tint). Without this, edits - // appear to "disappear" the moment the cell rebinds, which - // is exactly the symptom the user hit on undo. - // - // Drafts skip this branch — their cells live inside the - // tracker's `inserts.values` AND mirror onto RowObject - // (set_cell at edit time), so `row.cell_value(idx)` is - // already authoritative for them. - let value = tab_ctx_for_bind.effective_cell(&row, idx); + let (value, pending_classes) = tab_ctx_for_bind.bind_state(&row, idx); let is_null = matches!(value, Value::Null); // Editable cells render NULL as the italic sentinel — // distinguishes a true NULL from an empty string visually. @@ -411,55 +408,6 @@ fn build_column( value_to_display_text(&value) }; - // Query the per-tab change tracker for pending state on this - // (row, col). Apply tp-cell-modified / tp-row-pending-delete / - // tp-row-pending-insert CSS classes accordingly. Done at bind - // time so scroll-recycled widgets always reflect current - // tracker state without needing per-cell signal subscriptions. - // - // Draft rows are detected via RowObject's draft_id field - // (set when the row was created via the inline-Insert flow). - // Persisted rows are keyed by PK column values via - // RowKey::from_pk_values. - // - // Pending state is communicated through the row's BACKGROUND - // TINT alone (insert = green, modified = orange via - // `.tp-cell-modified`, delete = red+strikethrough). No - // leftmost-cell ribbon — that custom GNOME-Builder-style - // gutter felt foreign next to the AdwListView idiom, and - // the tint+strikethrough already make the row state legible - // at a glance. - let pending_classes: Vec<&'static str> = if tab_ctx_for_bind.tab_id.is_none() { - Vec::new() - } else if row.draft_id().is_some() { - vec!["tp-row-pending-insert"] - } else { - match tab_ctx_for_bind.tracked_key(&row) { - None => Vec::new(), - Some((tab_id, key)) => crate::services::change_tracker::with_tab_ref(tab_id, |t| { - let mut v: Vec<&'static str> = Vec::new(); - let row_state = t.row_state(&key); - let cell_state = t.cell_state(&key, idx); - use crate::services::change_tracker::{CellState, RowState}; - match (row_state, cell_state) { - (RowState::PendingDelete, _) => v.push("tp-row-pending-delete"), - (RowState::InsertDraft, _) => v.push("tp-row-pending-insert"), - (_, CellState::Modified) => v.push("tp-cell-modified"), - _ => {} - } - // Error-flash overlay on the leftmost cell — a - // transient background-only animation pulls the - // eye to the row that failed to commit. No - // accompanying gutter ribbon (see comment above). - if idx == 0 && t.is_error_row(&key) { - v.push("tp-row-leftmost-error-flash"); - } - v - }) - .unwrap_or_default(), - } - }; - let is_pending_delete = pending_classes.contains(&"tp-row-pending-delete"); let Some(child) = item.child() else { return }; if let Ok(label) = child.clone().downcast::() { @@ -2210,6 +2158,100 @@ mod tests { } } + fn cell_texts(root: &impl IsA) -> Vec { + crate::test_support::descendants(root) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .map(|cell| cell.text().to_string()) + .collect() + } + + #[gtk4::test] + fn edit_during_rebind_shows_pending_value() { + use crate::services::tracker_edit::TrackerEdit; + + let mut id = col("integer", true); + id.name = "id".into(); + let mut name = col("text", false); + name.name = "name".into(); + let columns = vec![id, name]; + let result = QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(1), Value::Text("old".into())]], + ); + let tracker = TabTrackerHandle::default(); + let (sender, _receiver) = relm4::channel::(); + let context = TabGridContext { + tracker: Some(tracker.clone()), + pk_col_indices: vec![0], + column_widths: None, + }; + let (view, selection) = build_column_view(&result, &columns, sender, true, None, None, context); + let window = gtk::Window::new(); + window.set_child(Some(&view)); + window.present(); + crate::test_support::wait_until(std::time::Duration::from_secs(5), || { + cell_texts(&view).iter().any(|text| text == "old") + }) + .expect("the fetched value is drawn"); + + let delta = tracker.apply(TrackerEdit::Cell { + row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + col: 1, + original: Value::Text("old".into()), + value: Value::Text("new".into()), + }); + // A fresh row object, because the list item manager skips the + // rebind for an items-changed that keeps the same item. + let store = selection + .model() + .and_downcast::() + .expect("a list store"); + let row = store.item(0).and_downcast::().expect("a row"); + store.splice(0, 1, &[RowObject::new(row.cells_clone())]); + + crate::test_support::wait_until(std::time::Duration::from_secs(5), || { + cell_texts(&view).iter().any(|text| text == "new") + }) + .expect("the rebind draws the pending value"); + assert_eq!(delta.pending_count, 1); + window.close(); + crate::test_support::drain_main_context(); + } + + #[test] + fn a_tracked_draft_carries_its_insert_tint_and_error_flash() { + let tracker = TabTrackerHandle::default(); + let delta = tracker.apply(crate::services::tracker_edit::TrackerEdit::Insert { + values: vec![Value::Null], + }); + let Some(crate::services::tracker_delta::VisualChange::InsertDraft { draft_id, values }) = + delta.visual.into_iter().next() + else { + panic!("an insert adds a draft row"); + }; + let _ = tracker.flash_error_row(RowKey::Draft(draft_id)); + let context = TabGridContext { + tracker: Some(tracker), + pk_col_indices: vec![0], + column_widths: None, + }; + + let (_, classes) = context.bind_state(&RowObject::new_draft(draft_id, values), 0); + + assert_eq!(classes, vec!["tp-row-pending-insert", "tp-row-leftmost-error-flash"]); + } + + #[test] + fn an_untracked_grid_draws_the_fetched_value_plainly() { + let row = RowObject::new(vec![Value::Int(1)]); + + let (value, classes) = TabGridContext::default().bind_state(&row, 0); + + assert_eq!(value, Value::Int(1)); + assert!(classes.is_empty()); + } + #[test] fn editable_for_normal_column() { assert!(is_cell_editable(&col("text", false))); diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index ddb3d8e549..1a6b3daeae 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -42,9 +42,8 @@ use tablepro_core::ddl::{ColumnDefinition, TableDefinition}; use tablepro_core::dialect::SqlDialect; use tablepro_core::sql_ddl::{BuildDdlError, DraftColumn}; use tablepro_core::{ForeignKeyInfo, IndexInfo}; -use uuid::Uuid; -use crate::services::structure_tracker; +use crate::services::structure_tracker_handle::StructureTrackerHandle; use crate::ui::structure_tab_dialogs::{present_fk_dialog, present_index_dialog}; use tablepro_core::sql_ddl::{StructureOp, diff_to_ops, materialize_ops}; @@ -66,7 +65,7 @@ pub enum StructureMode { #[derive(Debug)] pub struct StructureTabInit { - pub tab_id: Uuid, + pub tracker: StructureTrackerHandle, pub schema: Option, pub table: String, pub mode: StructureMode, @@ -82,7 +81,7 @@ pub struct StructureTabInit { } pub struct StructureTab { - tab_id: Uuid, + tracker: StructureTrackerHandle, schema: Option, table_name: Rc>, mode: Rc>, @@ -278,9 +277,7 @@ impl StructureTab { let count = ops.len(); self.refresh_buttons(count); self.regenerate_sql_preview_from(&ops); - - let ops_for_cache = ops.clone(); - structure_tracker::with_tab(self.tab_id, |t| t.set_ops(ops_for_cache)); + self.tracker.set_ops(ops); let dirty = count > 0; let mut last = self.last_dirty.borrow_mut(); @@ -520,8 +517,6 @@ impl SimpleComponent for StructureTab { } fn init(init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { - structure_tracker::open_tab(init.tab_id); - let view_stack = adw::ViewStack::new(); let columns_box = gtk::Box::builder() @@ -814,7 +809,7 @@ impl SimpleComponent for StructureTab { } let model = StructureTab { - tab_id: init.tab_id, + tracker: init.tracker, schema: init.schema, original_table_name: Rc::new(RefCell::new(init.table.clone())), table_name: Rc::new(RefCell::new(init.table)), @@ -1103,7 +1098,7 @@ impl SimpleComponent for StructureTab { // a New→Edit promotion doesn't leave a stale CREATE // TABLE statement visible in the preview pane until // StructureLoaded arrives. - structure_tracker::with_tab(self.tab_id, |t| t.clear()); + self.tracker.clear(); self.refresh_buttons(0); self.regenerate_sql_preview_from(&[]); let mut last = self.last_dirty.borrow_mut(); From 6dda85934f13a47c7d647751bd10abf2389e2263 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:28:16 +0700 Subject: [PATCH 013/178] ci(linux): fail fast checks on thread_local! in app services --- .github/workflows/build-linux.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index cbace07619..8ddd2ba9c9 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -98,6 +98,14 @@ jobs: run: meson setup _build -Dprofile=development - name: Format check run: cargo fmt --all -- --check + - name: No thread-local state in app services + # Trackers belong to a workspace; a thread_local! registry shares + # one window's unsaved edits with every other window. + run: | + if grep -rn 'thread_local!' crates/app/src/services; then + echo "::error::crates/app/src/services must not hold thread_local! state" + exit 1 + fi - name: Clippy run: meson devenv -C _build cargo clippy --locked --workspace --all-targets -- -D warnings - name: Clippy without default features From 3c3aae4cd25fa7fa148c3ac9191509d9fea91ddd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:28:41 +0700 Subject: [PATCH 014/178] fix(linux-editor): keep statements sqlformat would respace unformatted --- linux/CHANGELOG.md | 1 + linux/crates/app/src/ui/editor/format_plan.rs | 33 +++- .../app/src/ui/editor/significant_tokens.rs | 153 +++++++++++++++--- 3 files changed, 158 insertions(+), 29 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 5aa8e0614c..dddc7e9cfc 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -18,3 +18,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. +- Format could put a space inside `+=`, `b'01'` or `@@version` and change what the statement does. diff --git a/linux/crates/app/src/ui/editor/format_plan.rs b/linux/crates/app/src/ui/editor/format_plan.rs index 5e0d4445bc..ce95aec534 100644 --- a/linux/crates/app/src/ui/editor/format_plan.rs +++ b/linux/crates/app/src/ui/editor/format_plan.rs @@ -9,10 +9,11 @@ use super::significant_tokens::significant_tokens; /// sqlformat has its own lexer, and it does not know every dialect the /// app connects to: it reads `//` as an operator, ends a nested block /// comment at the first `*/`, treats a backslash as an escape inside -/// quotes, and takes `$$` for a keyword. Reformatting through it can -/// therefore change what a statement means. Each statement is checked -/// against its own tokens afterwards, and one that came back different -/// is written out as the user typed it. +/// quotes, takes `$$` for a keyword, and puts a space inside `+=` and +/// `b'01'`. Reformatting through it can therefore change what a +/// statement means. Each statement is checked against its own tokens +/// afterwards, and one that came back different is written out as the +/// user typed it. pub fn format_script(text: &str, grammar: SqlGrammar, settings: LexicalSettings) -> String { let plan = ScriptPlan::build(text, grammar, settings); let mut out = String::with_capacity(text.len()); @@ -161,6 +162,30 @@ mod tests { assert!(out.contains("DELIMITER ;"), "the delimiter reset was lost: {out}"); } + #[test] + fn statements_sqlformat_would_respace_into_something_else_kept_verbatim() { + let cases = [ + // `+=` would become `+ =`. + ("SET @x += 1", SqlGrammar::MsSql), + // A bit literal and a charset introducer would become a + // column with a string alias. + ("select b'01', _utf8mb4'x' from t", SqlGrammar::MySql), + // `@@ version` is not a system variable. + ("select @@version", SqlGrammar::MySql), + // `U & '…'` is a bitwise and, not a Unicode escape string. + (r"select U&'d\0061t'", SqlGrammar::PostgreSql), + // A string continued on the next line is one string; on + // one line the two are a syntax error. + ("select 'a'\n'b'", SqlGrammar::PostgreSql), + // A query parameter's name, colon and type are one unit. + ("SELECT {id:UInt32}", SqlGrammar::ClickHouse), + ]; + + for (source, grammar) in cases { + assert_eq!(format(source, grammar), source, "{grammar:?}"); + } + } + #[test] fn an_empty_script_stays_empty() { assert_eq!(format("", SqlGrammar::PostgreSql), ""); diff --git a/linux/crates/app/src/ui/editor/significant_tokens.rs b/linux/crates/app/src/ui/editor/significant_tokens.rs index fdb929501b..83a943a2f0 100644 --- a/linux/crates/app/src/ui/editor/significant_tokens.rs +++ b/linux/crates/app/src/ui/editor/significant_tokens.rs @@ -1,46 +1,121 @@ use tablepro_core::sql_syntax::SqlGrammar; use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptTokenKind}; -/// What a statement says, with the spacing taken out. +/// What a statement says, with the spacing taken out where spacing +/// cannot change it. /// /// Two texts with the same list are the same statement written /// differently, which is the only thing a formatter is allowed to do. -pub fn significant_tokens( - text: &str, - grammar: SqlGrammar, - settings: LexicalSettings, -) -> Vec<(ScriptTokenKind, String)> { +pub fn significant_tokens(text: &str, grammar: SqlGrammar, settings: LexicalSettings) -> Vec { let plan = ScriptPlan::build(text, grammar, settings); - plan.tokens(0..text.len()) - .filter(|token| token.kind != ScriptTokenKind::Whitespace) - .map(|token| { - let slice = text.get(token.range.clone()).unwrap_or_default(); - let body = match token.kind { - // A formatter may move the spaces at the end of a - // comment without changing what the comment says. - ScriptTokenKind::LineComment | ScriptTokenKind::BlockComment => slice.trim_end(), - _ => slice, - }; - (token.kind, body.to_owned()) - }) - .collect() + let mut tokens: Vec = Vec::new(); + let mut spacing = Spacing::default(); + for token in plan.tokens(0..text.len()) { + let slice = text.get(token.range.clone()).unwrap_or_default(); + if token.kind == ScriptTokenKind::Whitespace { + spacing.spaced = true; + spacing.line_break |= slice.contains('\n'); + continue; + } + let body = match token.kind { + // A formatter may move the spaces at the end of a + // comment without changing what the comment says. + ScriptTokenKind::LineComment | ScriptTokenKind::BlockComment => slice.trim_end(), + _ => slice, + }; + let gap = tokens + .last() + .map_or(Gap::Free, |previous| gap_between(previous, token.kind, body, spacing)); + tokens.push(SignificantToken { + kind: token.kind, + text: body.to_owned(), + gap, + }); + spacing = Spacing::default(); + } + tokens +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignificantToken { + kind: ScriptTokenKind, + text: String, + /// What separates it from the token before it. + gap: Gap, +} + +/// The space between two tokens, kept only where an engine reads the +/// two differently with and without it. +/// +/// The script lexer reads every operator character on its own, where +/// an engine reads `+=` or `->>` as one operator; and it reads `b'01'` +/// as a word and a string, where MySQL reads one bit literal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Gap { + Free, + Touching, + Spaced, + /// Two string literals with a line break between are one string in + /// PostgreSQL; on one line they are a syntax error. + LineBreak, +} + +#[derive(Debug, Clone, Copy, Default)] +struct Spacing { + spaced: bool, + line_break: bool, +} + +fn gap_between(previous: &SignificantToken, kind: ScriptTokenKind, text: &str, spacing: Spacing) -> Gap { + let strings = previous.kind == ScriptTokenKind::StringLiteral && kind == ScriptTokenKind::StringLiteral; + if strings && spacing.line_break { + return Gap::LineBreak; + } + let operator = previous.kind == ScriptTokenKind::Punct + && kind == ScriptTokenKind::Punct + && previous.text != "," + && text != ","; + let free = spacing_is_free(previous.kind, &previous.text) || spacing_is_free(kind, text); + if !strings && !operator && free { + return Gap::Free; + } + if spacing.spaced { Gap::Spaced } else { Gap::Touching } +} + +/// Whether spacing next to this token never changes what an engine +/// reads: a bracket, a separator, a comment, or an operator character +/// that is never part of a name, a literal or a parameter. +fn spacing_is_free(kind: ScriptTokenKind, text: &str) -> bool { + match kind { + ScriptTokenKind::OpenParen + | ScriptTokenKind::CloseParen + | ScriptTokenKind::Semicolon + | ScriptTokenKind::LineComment + | ScriptTokenKind::BlockComment => true, + ScriptTokenKind::Punct => matches!( + text, + "," | "." | "=" | "<" | ">" | "+" | "-" | "*" | "/" | "%" | "^" | "|" | "!" | "~" | "[" | "]" + ), + _ => false, + } } #[cfg(test)] mod tests { use super::*; - fn tokens(text: &str) -> Vec<(ScriptTokenKind, String)> { - significant_tokens( - text, - SqlGrammar::PostgreSql, - LexicalSettings::default_for(SqlGrammar::PostgreSql), - ) + fn tokens_for(text: &str, grammar: SqlGrammar) -> Vec { + significant_tokens(text, grammar, LexicalSettings::default_for(grammar)) + } + + fn tokens(text: &str) -> Vec { + tokens_for(text, SqlGrammar::PostgreSql) } #[test] fn spacing_alone_does_not_change_a_statement() { assert_eq!(tokens("select a ,b from t"), tokens("select a, b\nfrom t")); + assert_eq!(tokens("select a=-1,(1,-1)"), tokens("select a =- 1, (1, -1)")); } #[test] @@ -53,4 +128,32 @@ mod tests { assert_eq!(tokens("select 1 -- note "), tokens("select 1 -- note")); assert_ne!(tokens("select 1 -- note"), tokens("select 1 -- other")); } + + #[test] + fn an_operator_split_by_a_space_changes_the_statement() { + assert_ne!( + tokens_for("SET @x += 1", SqlGrammar::MsSql), + tokens_for("SET @x + = 1", SqlGrammar::MsSql) + ); + assert_ne!(tokens("select a->>'k'"), tokens("select a- >>'k'")); + assert_eq!(tokens("select a->>'k'"), tokens("select a ->> 'k'")); + } + + #[test] + fn a_prefix_split_from_its_string_changes_the_statement() { + assert_ne!( + tokens_for("select b'01'", SqlGrammar::MySql), + tokens_for("select b '01'", SqlGrammar::MySql) + ); + assert_ne!( + tokens_for("select @@version", SqlGrammar::MySql), + tokens_for("select @@ version", SqlGrammar::MySql) + ); + } + + #[test] + fn strings_joined_onto_one_line_change_the_statement() { + assert_ne!(tokens("select 'a'\n'b'"), tokens("select 'a' 'b'")); + assert_eq!(tokens("select 'a'\n'b'"), tokens("select\n 'a'\n 'b'")); + } } From 47d59f438681effdd2b15204111f2dcb1dacf37d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:28:53 +0700 Subject: [PATCH 015/178] fix(linux-postgres): tell a statement timeout apart by time, not text --- linux/CHANGELOG.md | 1 + linux/crates/drivers/postgres/src/cancel.rs | 4 +- .../crates/drivers/postgres/src/connection.rs | 6 +- linux/crates/drivers/postgres/src/errors.rs | 27 ++-- .../drivers/postgres/src/workspace_call.rs | 146 +++++++++++++++--- .../drivers/postgres/src/write_batch.rs | 17 +- 6 files changed, 153 insertions(+), 48 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 5aa8e0614c..1da9d968c8 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -18,3 +18,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. +- PostgreSQL statement timeouts showed as a plain server error when the server's messages were not in English. diff --git a/linux/crates/drivers/postgres/src/cancel.rs b/linux/crates/drivers/postgres/src/cancel.rs index bfc16afca5..df2d70ffc7 100644 --- a/linux/crates/drivers/postgres/src/cancel.rs +++ b/linux/crates/drivers/postgres/src/cancel.rs @@ -6,7 +6,7 @@ use tablepro_core::{ }; use tokio_postgres::CancelToken; -use crate::errors::sqlstate; +use crate::errors::{QUERY_CANCELED, sqlstate}; use crate::physical::PgEndpoint; /// How far a call's cancel request got. @@ -98,7 +98,7 @@ impl EngineStop for PgStop { /// Whether the server answered a statement with 57014, which is how it /// reports a cancel request or a statement timeout taking effect. pub(crate) fn acknowledges_stop(error: &DriverError) -> bool { - sqlstate(error) == Some("57014") + sqlstate(error) == Some(QUERY_CANCELED) } fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { diff --git a/linux/crates/drivers/postgres/src/connection.rs b/linux/crates/drivers/postgres/src/connection.rs index 08e002dfc8..4e4d95a49c 100644 --- a/linux/crates/drivers/postgres/src/connection.rs +++ b/linux/crates/drivers/postgres/src/connection.rs @@ -29,7 +29,7 @@ use crate::physical::PgEndpoint; use crate::pool::{probe_timeouts, workspace_timeouts}; use crate::standalone::Standalone; use crate::stream_read::stream; -use crate::workspace_call::{Access, ReadOp, count_op, flight, preamble, query_bound_op, read_op, statement_bound}; +use crate::workspace_call::{Access, Preamble, ReadOp, count_op, flight, query_bound_op, read_op, statement_bound}; use crate::write_batch::{WritePlan, perform, precheck}; /// A workspace's link to one PostgreSQL server: a pool of four @@ -114,8 +114,8 @@ impl PgConnection { self.settle(outcome, &stop).await } - fn read_preamble(&self, options: &CallOptions) -> String { - preamble( + fn read_preamble(&self, options: &CallOptions) -> Preamble { + Preamble::new( Access::ReadOnly, Some(statement_bound(&self.settings, options.deadline())), self.settings.lock_timeout, diff --git a/linux/crates/drivers/postgres/src/errors.rs b/linux/crates/drivers/postgres/src/errors.rs index a1075eea13..5a6fad90e8 100644 --- a/linux/crates/drivers/postgres/src/errors.rs +++ b/linux/crates/drivers/postgres/src/errors.rs @@ -12,6 +12,9 @@ use tokio_postgres::error::{DbError, ErrorPosition}; const AUTHENTICATION_KIND: &str = "authentication error"; const TLS_KIND: &str = "error performing TLS handshake"; +/// query_canceled: a cancel request or a statement timeout took effect. +pub(crate) const QUERY_CANCELED: &str = "57014"; + /// What a tokio-postgres error means to the app. pub(crate) fn map_error(error: &tokio_postgres::Error) -> DriverError { if let Some(carried) = carried(error) { @@ -71,12 +74,6 @@ pub(crate) fn map_diagnostics(diagnostics: ServerDiagnostics) -> DriverError { match diagnostics.sqlstate().unwrap_or_default() { "28P01" | "28000" => DriverError::auth(Some(diagnostics)), "25006" => DriverError::ReadOnly(ReadOnlyRefusal::server(diagnostics)), - // The SQLSTATE of a statement_timeout is the one a cancel request - // raises too, and only the message tells them apart. - "57014" if diagnostics.message.contains("statement timeout") => DriverError::Timeout { - phase: TimeoutPhase::Statement, - server_cancelled: true, - }, "55P03" => DriverError::Timeout { phase: TimeoutPhase::LockWait, server_cancelled: true, @@ -119,7 +116,7 @@ pub(crate) fn sqlstate(error: &DriverError) -> Option<&str> { DriverError::Timeout { phase: TimeoutPhase::Statement, server_cancelled: true, - } => Some("57014"), + } => Some(QUERY_CANCELED), DriverError::Timeout { phase: TimeoutPhase::LockWait, .. @@ -194,15 +191,13 @@ mod tests { ("25006", "cannot execute INSERT in a read-only transaction", |e| { matches!(e, DriverError::ReadOnly(ReadOnlyRefusal::ServerRejected(_))) }), - ("57014", "canceling statement due to statement timeout", |e| { - matches!( - e, - DriverError::Timeout { - phase: TimeoutPhase::Statement, - server_cancelled: true - } - ) - }), + // Only the call that set the timeout can tell it from a + // cancel, so here both stay the server's own answer. + ( + "57014", + "canceling statement due to statement timeout", + |e| matches!(e, DriverError::Server(d) if d.sqlstate() == Some("57014")), + ), ( "57014", "canceling statement due to user request", diff --git a/linux/crates/drivers/postgres/src/workspace_call.rs b/linux/crates/drivers/postgres/src/workspace_call.rs index e7d179d681..b87446f939 100644 --- a/linux/crates/drivers/postgres/src/workspace_call.rs +++ b/linux/crates/drivers/postgres/src/workspace_call.rs @@ -5,13 +5,13 @@ use std::time::Duration; use futures_util::{StreamExt, TryStreamExt}; use tablepro_core::column::ResultColumn; use tablepro_core::statement::Statement; -use tablepro_core::{DriverError, QueryResult, RowLimit, Value, WorkspaceSettings}; +use tablepro_core::{DriverError, QueryResult, RowLimit, TimeoutPhase, Value, WorkspaceSettings}; use tokio::time::Instant; use tokio_postgres::Client; use crate::catalog::result_column; use crate::codec::{RawCell, typed}; -use crate::errors::map_error; +use crate::errors::{QUERY_CANCELED, map_error}; use crate::pg_call::PgCall; pub(crate) type BoxFuture<'a, T> = Pin + Send + 'a>>; @@ -40,16 +40,52 @@ pub(crate) enum Access { /// /// `statement_timeout` of `None` sends 0, the server's "no limit". A /// bound is never rendered as 0 otherwise, since that would lift it. -pub(crate) fn preamble(access: Access, statement_timeout: Option, lock_timeout: Duration) -> String { - let mode = match access { - Access::ReadOnly => "READ ONLY", - Access::ReadWrite => "READ WRITE", - }; - let statement = statement_timeout.map_or(0, millis); - format!( - "START TRANSACTION {mode}; SET LOCAL statement_timeout = {statement}; SET LOCAL lock_timeout = {}", - millis(lock_timeout) - ) +#[derive(Debug, Clone)] +pub(crate) struct Preamble { + sql: String, + statement_timeout_ms: Option, +} + +impl Preamble { + pub(crate) fn new(access: Access, statement_timeout: Option, lock_timeout: Duration) -> Self { + let mode = match access { + Access::ReadOnly => "READ ONLY", + Access::ReadWrite => "READ WRITE", + }; + let statement_timeout_ms = statement_timeout.map(millis); + let sql = format!( + "START TRANSACTION {mode}; SET LOCAL statement_timeout = {}; SET LOCAL lock_timeout = {}", + statement_timeout_ms.unwrap_or(0), + millis(lock_timeout) + ); + Self { + sql, + statement_timeout_ms, + } + } + + pub(crate) fn sql(&self) -> &str { + &self.sql + } + + /// A 57014 no stop asked for is this transaction's statement timeout + /// or someone else's cancel, and only the message, which + /// `lc_messages` translates, tells them apart. The server never fires + /// the timeout early, so one that came after the bound is the timeout. + pub(crate) fn explain(&self, error: DriverError, started: Instant) -> DriverError { + let ran_out = self + .statement_timeout_ms + .is_some_and(|bound| started.elapsed().as_millis() >= bound); + match error { + DriverError::Server(diagnostics) if ran_out && diagnostics.sqlstate() == Some(QUERY_CANCELED) => { + DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: true, + } + } + other => other, + } + } } /// Whole milliseconds, at least one: a sub-millisecond remainder still @@ -72,10 +108,11 @@ pub(crate) fn statement_bound(settings: &WorkspaceSettings, deadline: Option(call: &PgCall, preamble: &str, op: ReadOp) -> Result { +pub(crate) async fn flight(call: &PgCall, preamble: &Preamble, op: ReadOp) -> Result { let client = call.client()?; + let started = Instant::now(); let (begun, result, committed) = futures_util::join!( - client.batch_execute(preamble), + client.batch_execute(preamble.sql()), op(client), client.batch_execute("COMMIT") ); @@ -84,7 +121,7 @@ pub(crate) async fn flight(call: &PgCall, preamble: &str, op: ReadOp) -> R } match (begun, result, committed) { (Err(error), _, _) => Err(map_error(&error)), - (Ok(()), Err(error), _) => Err(error), + (Ok(()), Err(error), _) => Err(preamble.explain(error, started)), (Ok(()), Ok(_), Err(error)) => Err(map_error(&error)), (Ok(()), Ok(value), Ok(())) => Ok(value), } @@ -92,7 +129,7 @@ pub(crate) async fn flight(call: &PgCall, preamble: &str, op: ReadOp) -> R /// Read at most `limit` rows of a generated statement, and one more to /// learn whether the result was cut short. -pub(crate) fn query_bound_op(statement: Statement, limit: RowLimit, begin: String) -> ReadOp { +pub(crate) fn query_bound_op(statement: Statement, limit: RowLimit, begin: Preamble) -> ReadOp { read_op(move |client| { Box::pin(async move { let stream = client @@ -132,9 +169,9 @@ pub(crate) fn query_bound_op(statement: Statement, limit: RowLimit, begin: Strin /// row of NULLs, typed as its columns, and the planner skips the /// subquery itself. The outer flight's COMMIT has already gone out, so /// this one is its own read-only transaction. -async fn describe_in_own_flight(client: &Client, statement: &Statement, begin: &str) -> Vec { +async fn describe_in_own_flight(client: &Client, statement: &Statement, begin: &Preamble) -> Vec { let (_, described, _) = futures_util::join!( - client.batch_execute(begin), + client.batch_execute(begin.sql()), describe(client, statement), client.batch_execute("COMMIT") ); @@ -192,11 +229,31 @@ pub(crate) fn decode_row(row: &tokio_postgres::Row) -> Result, Driver #[cfg(test)] mod tests { + use tablepro_core::{ServerCode, ServerDiagnostics}; + use super::*; + fn sql(access: Access, statement_timeout: Option, lock_timeout: Duration) -> String { + Preamble::new(access, statement_timeout, lock_timeout).sql().to_owned() + } + + fn cancelled(message: &str) -> DriverError { + DriverError::reported(ServerDiagnostics::new( + Some(ServerCode::SqlState(QUERY_CANCELED.to_owned())), + message, + )) + } + + fn timed_out() -> DriverError { + DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: true, + } + } + #[test] fn preamble_uses_integer_milliseconds() { - let preamble = preamble( + let preamble = sql( Access::ReadOnly, Some(Duration::from_millis(1_500)), Duration::from_secs(10), @@ -210,7 +267,7 @@ mod tests { #[test] fn a_sub_millisecond_bound_never_renders_as_zero() { - let preamble = preamble(Access::ReadWrite, Some(Duration::from_micros(300)), Duration::ZERO); + let preamble = sql(Access::ReadWrite, Some(Duration::from_micros(300)), Duration::ZERO); assert!(preamble.contains("statement_timeout = 1;"), "{preamble}"); assert!(preamble.ends_with("lock_timeout = 1"), "{preamble}"); @@ -219,7 +276,7 @@ mod tests { #[test] fn an_unbounded_schema_change_sends_the_servers_no_limit() { - assert!(preamble(Access::ReadWrite, None, Duration::from_secs(10)).contains("statement_timeout = 0;")); + assert!(sql(Access::ReadWrite, None, Duration::from_secs(10)).contains("statement_timeout = 0;")); } #[tokio::test(start_paused = true)] @@ -230,4 +287,51 @@ mod tests { let near = Instant::now() + Duration::from_secs(2); assert_eq!(statement_bound(&settings, Some(near)), Duration::from_secs(2)); } + + /// The message is the server's, in whatever language `lc_messages` + /// picked, so only the time tells the timeout apart. + #[tokio::test(start_paused = true)] + async fn a_57014_once_the_bound_ran_out_is_the_timeout_in_any_language() { + let preamble = Preamble::new( + Access::ReadOnly, + Some(Duration::from_millis(1_500)), + Duration::from_secs(10), + ); + let started = Instant::now(); + tokio::time::advance(Duration::from_millis(1_500)).await; + + for message in [ + "canceling statement due to statement timeout", + "Anweisung wird abgebrochen wegen Zeitüberschreitung", + "annulation de la requête à cause du délai écoulé pour l'exécution de l'instruction", + ] { + assert_eq!(preamble.explain(cancelled(message), started), timed_out(), "{message}"); + } + } + + #[tokio::test(start_paused = true)] + async fn a_57014_before_the_bound_or_without_one_is_the_servers_answer() { + let bounded = Preamble::new( + Access::ReadOnly, + Some(Duration::from_millis(1_500)), + Duration::from_secs(10), + ); + let unbounded = Preamble::new(Access::ReadWrite, None, Duration::from_secs(10)); + let started = Instant::now(); + tokio::time::advance(Duration::from_millis(1_499)).await; + + assert_eq!( + bounded.explain(cancelled("canceling statement due to statement timeout"), started), + cancelled("canceling statement due to statement timeout") + ); + tokio::time::advance(Duration::from_secs(60)).await; + assert_eq!( + unbounded.explain(cancelled("canceling statement due to user request"), started), + cancelled("canceling statement due to user request") + ); + assert_eq!( + bounded.explain(DriverError::server("relation does not exist"), started), + DriverError::server("relation does not exist") + ); + } } diff --git a/linux/crates/drivers/postgres/src/write_batch.rs b/linux/crates/drivers/postgres/src/write_batch.rs index dab114b87d..591c7f07d8 100644 --- a/linux/crates/drivers/postgres/src/write_batch.rs +++ b/linux/crates/drivers/postgres/src/write_batch.rs @@ -7,13 +7,14 @@ use tablepro_core::{ CommitToken, DriverError, ReadOnlyRefusal, SessionFate, StopRequest, WorkspaceSettings, WriteBatch, WriteKind, WriteLedger, WriteReport, }; +use tokio::time::Instant; use tokio_postgres::Client; use crate::cancel::{PgStop, acknowledges_stop}; use crate::codec::{RawCell, typed}; use crate::errors::map_error; use crate::pg_call::PgCall; -use crate::workspace_call::{Access, preamble}; +use crate::workspace_call::{Access, Preamble}; /// Refuse a batch before anything is sent: a read-only connection never /// writes, and PostgreSQL has no way to suspend foreign keys. @@ -49,8 +50,9 @@ pub(crate) async fn perform( WriteKind::RowChanges => Some(plan.settings.statement_timeout), WriteKind::SchemaChanges => None, }; - let begin = preamble(Access::ReadWrite, statement_timeout, plan.settings.lock_timeout); - if let Err(error) = client.batch_execute(&begin).await { + let begin = Preamble::new(Access::ReadWrite, statement_timeout, plan.settings.lock_timeout); + let started = Instant::now(); + if let Err(error) = client.batch_execute(begin.sql()).await { let error = map_error(&error); let fate = rollback(call, client).await; return Err(ledger.failed(0, error, fate)); @@ -68,7 +70,10 @@ pub(crate) async fn perform( let fate = rollback(call, client).await; return Err(ledger.guard_failed(index, expected, actual, fate)); } - Err(error) => return Err(step_failed(call, client, plan, index, error, request, stop).await), + Err(error) => { + let error = begin.explain(error, started); + return Err(step_failed(call, client, plan, index, error, request, stop).await); + } } } ledger.statement_sending(index); @@ -80,7 +85,7 @@ pub(crate) async fn perform( let affected = match client.execute_typed(step.statement.sql(), &borrowed).await { Ok(affected) => affected, Err(error) => { - let error = map_error(&error); + let error = begin.explain(map_error(&error), started); return Err(step_failed(call, client, plan, index, error, request, stop).await); } }; @@ -107,7 +112,7 @@ pub(crate) async fn perform( // constraint or a serialization failure rolled it all back. Err(error) if error.as_db_error().is_some() => Err(DriverError::RolledBack { statement_index: plan.batch.steps.len(), - source: Box::new(map_error(&error)), + source: Box::new(begin.explain(map_error(&error), started)), }), Err(error) => { call.taint(); From 200717c150be859713fb719e92c43987a79f449e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:28:56 +0700 Subject: [PATCH 016/178] refactor(linux-editor): map Run at Cursor through one statement helper --- linux/crates/app/src/ui/editor/mod.rs | 12 +- linux/crates/app/src/ui/editor/run_scope.rs | 44 +------ .../app/src/ui/editor/statement_cursor.rs | 116 ++++++++++++++---- linux/crates/app/src/ui/editor/tests.rs | 15 +++ 4 files changed, 118 insertions(+), 69 deletions(-) diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index a4a3fd9dec..f72ddb4d1d 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -186,18 +186,22 @@ impl SqlEditor { /// Run the statement the cursor is in, selecting it so the user sees /// which of the buffer's queries ran. pub fn run_at_cursor(&self) { + if self.is_running() { + return; + } let buffer = self.buffer(); let text = buffer_text(&buffer); let plan = statement_cursor::plan_for(&text, self.grammar()); let cursor = usize::try_from(buffer.iter_at_mark(&buffer.get_insert()).offset()).unwrap_or_default(); - let Some((scoped, range)) = run_scope::at_cursor(&text, &plan, cursor) else { + let Some((start, end)) = statement_cursor::statement_bounds_at(&buffer, &plan, &text, cursor) else { self.say(&crate::i18n::gettext("No statement at the cursor.")); return; }; - let start = buffer.iter_at_offset(i32::try_from(range.start).unwrap_or(i32::MAX)); - let end = buffer.iter_at_offset(i32::try_from(range.end).unwrap_or(i32::MAX)); buffer.select_range(&start, &end); - self.execute(scoped); + self.execute(ScopedText { + text: buffer.text(&start, &end, false).to_string(), + base_char: usize::try_from(start.offset()).unwrap_or_default(), + }); } /// Reformat the buffer per statement, as one undo step, keeping the diff --git a/linux/crates/app/src/ui/editor/run_scope.rs b/linux/crates/app/src/ui/editor/run_scope.rs index 479022c345..658a00bb63 100644 --- a/linux/crates/app/src/ui/editor/run_scope.rs +++ b/linux/crates/app/src/ui/editor/run_scope.rs @@ -1,6 +1,6 @@ use std::ops::Range; -use tablepro_core::sql_syntax::script::{ScriptPlan, text_offsets}; +use tablepro_core::sql_syntax::script::text_offsets; /// The SQL one run sends, and where it starts in the buffer. #[derive(Debug, Clone, PartialEq, Eq)] @@ -27,26 +27,6 @@ pub fn for_run(buffer_text: &str, selection: Option>) -> Option Option<(ScopedText, Range)> { - let byte = text_offsets::char_to_byte(buffer_text, cursor_char); - let statement = plan.statement_at(byte)?; - let start = text_offsets::byte_to_char(buffer_text, statement.range.start); - let end = text_offsets::byte_to_char(buffer_text, statement.range.end); - let text = statement.text(buffer_text); - if text.trim().is_empty() { - return None; - } - Some(( - ScopedText { - text: text.to_owned(), - base_char: start, - }, - start..end, - )) -} - fn char_slice(text: &str, range: Range) -> &str { let start = text_offsets::char_to_byte(text, range.start); let end = text_offsets::char_to_byte(text, range.end); @@ -55,9 +35,6 @@ fn char_slice(text: &str, range: Range) -> &str { #[cfg(test)] mod tests { - use tablepro_core::sql_syntax::SqlGrammar; - use tablepro_core::sql_syntax::script::LexicalSettings; - use super::*; #[test] @@ -83,23 +60,4 @@ mod tests { assert_eq!(selected.text, "SELECT 2"); } - - #[test] - fn run_scope_statement_at_cursor_uses_plan() { - let buffer = "CREATE FUNCTION f() RETURNS int AS $$ SELECT 1; $$ LANGUAGE sql;\nSELECT 2;"; - let plan = ScriptPlan::build( - buffer, - SqlGrammar::PostgreSql, - LexicalSettings::default_for(SqlGrammar::PostgreSql), - ); - - let (inside_body, range) = at_cursor(buffer, &plan, 40).expect("a statement"); - assert!(inside_body.text.starts_with("CREATE FUNCTION"), "{}", inside_body.text); - assert!(inside_body.text.ends_with("LANGUAGE sql"), "{}", inside_body.text); - assert_eq!(range.start, 0); - - let (second, _) = at_cursor(buffer, &plan, buffer.chars().count()).expect("the last statement"); - assert_eq!(second.text, "SELECT 2"); - assert_eq!(second.base_char, buffer.find("SELECT 2").unwrap_or_default()); - } } diff --git a/linux/crates/app/src/ui/editor/statement_cursor.rs b/linux/crates/app/src/ui/editor/statement_cursor.rs index 4c634575ad..7d97bd7255 100644 --- a/linux/crates/app/src/ui/editor/statement_cursor.rs +++ b/linux/crates/app/src/ui/editor/statement_cursor.rs @@ -1,5 +1,6 @@ +use gtk4::prelude::*; use tablepro_core::sql_syntax::SqlGrammar; -use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan}; +use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, text_offsets}; /// The script the buffer holds, read with the engine's own rules: a /// dollar-quoted body, a `GO` batch or a procedure between `DELIMITER` @@ -8,46 +9,117 @@ pub fn plan_for(text: &str, grammar: SqlGrammar) -> ScriptPlan { ScriptPlan::build(text, grammar, LexicalSettings::default_for(grammar)) } +/// Where the statement under the cursor starts and ends in the buffer. +/// +/// The buffer counts characters and the plan counts bytes, so every +/// mapping between the two goes through here: a cursor in a Vietnamese +/// identifier lands mid-character otherwise. +pub fn statement_bounds_at( + buffer: >k4::TextBuffer, + plan: &ScriptPlan, + text: &str, + cursor_char: usize, +) -> Option<(gtk4::TextIter, gtk4::TextIter)> { + let byte = text_offsets::char_to_byte(text, cursor_char); + let statement = plan.statement_at(byte)?; + Some(( + iter_for_byte(buffer, text, statement.range.start), + iter_for_byte(buffer, text, statement.range.end), + )) +} + +/// The buffer position of a byte offset into `text`, which must be the +/// buffer's text. Past the end is the end. +pub fn iter_for_byte(buffer: >k4::TextBuffer, text: &str, byte: usize) -> gtk4::TextIter { + let chars = i32::try_from(text_offsets::byte_to_char(text, byte)).unwrap_or(i32::MAX); + buffer.iter_at_offset(chars) +} + #[cfg(test)] mod tests { - use super::super::run_scope::at_cursor; use super::*; - fn statement_at(text: &str, cursor_char: usize) -> Option { + fn buffer(text: &str) -> gtk4::TextBuffer { + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text(text); + buffer + } + + fn bounds_at(text: &str, cursor_char: usize) -> Option<(i32, i32, String)> { + let buffer = buffer(text); let plan = plan_for(text, SqlGrammar::PostgreSql); - at_cursor(text, &plan, cursor_char).map(|(scoped, _)| scoped.text) + let (start, end) = statement_bounds_at(&buffer, &plan, text, cursor_char)?; + Some(( + start.offset(), + end.offset(), + buffer.text(&start, &end, false).to_string(), + )) } - #[test] + #[gtk4::test] + fn statement_bounds_multibyte_text() { + let text = "SELECT 'tên'; SELECT \"bảng\" FROM t"; + + let (start, end, statement) = bounds_at(text, 30).expect("a statement"); + + assert_eq!(statement, "SELECT \"bảng\" FROM t"); + assert_eq!((start, end), (14, 34), "the bounds were not mapped to characters"); + } + + #[gtk4::test] + fn a_cursor_in_a_multibyte_identifier_stays_in_its_statement() { + // Character 9 is inside the quoted identifier, which is three + // bytes further along than it is characters. + let (_, _, statement) = bounds_at("SELECT \"tên\" FROM t; SELECT 2", 9).expect("a statement"); + + assert_eq!(statement, "SELECT \"tên\" FROM t"); + } + + #[gtk4::test] fn the_cursor_picks_the_statement_it_sits_in() { - let sql = "SELECT 1; SELECT 2"; + let text = "SELECT 1; SELECT 2"; - assert_eq!(statement_at(sql, 4).as_deref(), Some("SELECT 1")); - assert_eq!(statement_at(sql, 17).as_deref(), Some("SELECT 2")); + assert_eq!(bounds_at(text, 4).map(|(_, _, sql)| sql).as_deref(), Some("SELECT 1")); + assert_eq!(bounds_at(text, 17).map(|(_, _, sql)| sql).as_deref(), Some("SELECT 2")); } - #[test] + #[gtk4::test] fn a_cursor_past_the_end_picks_the_last_statement() { - assert_eq!(statement_at("SELECT 1; SELECT 2", 9999).as_deref(), Some("SELECT 2")); + let statement = bounds_at("SELECT 1; SELECT 2", 9999).map(|(_, _, sql)| sql); + + assert_eq!(statement.as_deref(), Some("SELECT 2")); } - #[test] + #[gtk4::test] fn a_semicolon_inside_a_string_is_not_a_boundary() { - let sql = "INSERT INTO t VALUES ('a;b'); SELECT 2"; + let (_, _, statement) = bounds_at("INSERT INTO t VALUES ('a;b'); SELECT 2", 24).expect("a statement"); + + assert_eq!(statement, "INSERT INTO t VALUES ('a;b')"); + } - let statement = statement_at(sql, 24).expect("a statement"); + #[gtk4::test] + fn a_dollar_quoted_body_is_one_statement() { + let text = "CREATE FUNCTION f() RETURNS int AS $$ SELECT 1; $$ LANGUAGE sql;\nSELECT 2;"; - assert!(statement.starts_with("INSERT INTO t VALUES"), "{statement}"); - assert!(statement.contains("'a;b'"), "{statement}"); + let (start, _, statement) = bounds_at(text, 40).expect("a statement"); + + assert_eq!(start, 0); + assert!(statement.ends_with("LANGUAGE sql"), "{statement}"); } - #[test] - fn a_cursor_in_a_multibyte_identifier_stays_in_its_statement() { - let sql = "SELECT \"tên\" FROM t; SELECT 2"; - // Character 9 is inside the quoted identifier, which is three - // bytes further along than it is characters. - let statement = statement_at(sql, 9).expect("a statement"); + #[gtk4::test] + fn an_empty_buffer_has_no_statement() { + assert_eq!(bounds_at("", 0), None); + assert_eq!(bounds_at(" \n -- only a comment\n", 3), None); + } + + #[gtk4::test] + fn iter_for_byte_at_end() { + let text = "SELECT 'tên'"; + let buffer = buffer(text); - assert!(statement.starts_with("SELECT \"tên\""), "{statement}"); + assert!(iter_for_byte(&buffer, text, text.len()).is_end()); + assert!(iter_for_byte(&buffer, text, text.len() + 10).is_end()); + assert_eq!(iter_for_byte(&buffer, text, text.len() - 1).offset(), 11); } } diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 155f5c6985..1ac2f4a6d9 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -95,6 +95,21 @@ fn selected_text_is_the_only_sql_sent() { assert_eq!(sent(&harness.fake), ["SELECT 2"]); } +#[gtk4::test] +fn run_at_cursor_selects_executed_statement() { + let harness = editor_with(answering(), "SELECT 'tên';\nSELECT 2 FROM t;\nSELECT 3;"); + let buffer = harness.editor.buffer(); + buffer.place_cursor(&buffer.iter_at_offset(20)); + + harness.editor.run_at_cursor(); + assert!(harness.editor.is_running(), "the run did not start"); + wait_until(WAIT, || !harness.editor.is_running()).expect("the run to end"); + + assert_eq!(sent(&harness.fake), ["SELECT 2 FROM t"]); + let (start, end) = buffer.selection_bounds().expect("the statement is selected"); + assert_eq!((start.offset(), end.offset()), (14, 29)); +} + #[gtk4::test] fn a_script_fills_the_results_list_one_line_per_statement() { let mut runs = Replies::default(); From db0a29f6d8ff5cf1dfb3bf6cb2d4781ce2ba3d50 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:28:57 +0700 Subject: [PATCH 017/178] fix(linux-postgres): run every table definition query inside its read --- .../drivers/postgres/src/metadata/mod.rs | 19 +- .../drivers/postgres/src/workspace_call.rs | 4 + .../postgres/tests/integration/flight.rs | 192 ++++++++++++++++++ .../postgres/tests/integration/main.rs | 1 + 4 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 linux/crates/drivers/postgres/tests/integration/flight.rs diff --git a/linux/crates/drivers/postgres/src/metadata/mod.rs b/linux/crates/drivers/postgres/src/metadata/mod.rs index 07b845a6d9..e8386e44ce 100644 --- a/linux/crates/drivers/postgres/src/metadata/mod.rs +++ b/linux/crates/drivers/postgres/src/metadata/mod.rs @@ -69,9 +69,17 @@ pub(crate) async fn fetch_table_definition( table: &TableRef, relation: &str, ) -> Result { - let columns = column_rows(client, relation).await?; - let primary_key = text_rows(client, PRIMARY_KEY_NAME_SQL, &[relation]) - .await? + // Every query is sent before the first answer is awaited, so all of + // them run inside the flight's transaction, ahead of its COMMIT. + let params = [relation]; + let (columns, primary_key, indexes, foreign_keys, inbound_foreign_keys) = futures_util::try_join!( + column_rows(client, relation), + text_rows(client, PRIMARY_KEY_NAME_SQL, ¶ms), + fetch_indexes(client, relation), + fetch_foreign_keys(client, relation), + text_rows(client, INBOUND_FOREIGN_KEYS_SQL, ¶ms), + )?; + let primary_key = primary_key .first() .map(|row| -> Result { let field = decode_error("pg_constraint"); @@ -81,10 +89,7 @@ pub(crate) async fn fetch_table_definition( }) }) .transpose()?; - let indexes = fetch_indexes(client, relation).await?; - let foreign_keys = fetch_foreign_keys(client, relation).await?; - let inbound_foreign_keys = text_rows(client, INBOUND_FOREIGN_KEYS_SQL, &[relation]) - .await? + let inbound_foreign_keys = inbound_foreign_keys .iter() .map(inbound_foreign_key) .collect::>()?; diff --git a/linux/crates/drivers/postgres/src/workspace_call.rs b/linux/crates/drivers/postgres/src/workspace_call.rs index b87446f939..274266f90f 100644 --- a/linux/crates/drivers/postgres/src/workspace_call.rs +++ b/linux/crates/drivers/postgres/src/workspace_call.rs @@ -108,6 +108,10 @@ pub(crate) fn statement_bound(settings: &WorkspaceSettings, deadline: Option(call: &PgCall, preamble: &Preamble, op: ReadOp) -> Result { let client = call.client()?; let started = Instant::now(); diff --git a/linux/crates/drivers/postgres/tests/integration/flight.rs b/linux/crates/drivers/postgres/tests/integration/flight.rs new file mode 100644 index 0000000000..b0b3f06e33 --- /dev/null +++ b/linux/crates/drivers/postgres/tests/integration/flight.rs @@ -0,0 +1,192 @@ +//! A workspace read is one transaction on the wire: its preamble, every +//! query it makes, then its COMMIT. + +use std::io; +use std::pin::Pin; +use std::sync::{Arc, Mutex, PoisonError}; +use std::task::{Context, Poll}; + +use async_trait::async_trait; +use tablepro_core::meta::TableRef; +use tablepro_core::{ + NetworkEndpoint, TlsConfig, Transport, TransportError, TransportKind, TransportRoute, TransportStream, + WorkspaceSettings, +}; +use tablepro_test_support::conformance::test_liveness; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +use crate::support::{CALL, connect_with, direct, options_over, password, setup, start, within}; + +type Log = Arc>>; + +/// A direct route that keeps what the client writes on each stream. +#[derive(Debug)] +struct RecordingTransport { + inner: Arc, + streams: Mutex>, +} + +impl RecordingTransport { + fn written(&self) -> Vec> { + self.streams + .lock() + .unwrap_or_else(PoisonError::into_inner) + .iter() + .map(|log| log.lock().unwrap_or_else(PoisonError::into_inner).clone()) + .collect() + } +} + +#[async_trait] +impl Transport for RecordingTransport { + fn service_endpoint(&self) -> &NetworkEndpoint { + self.inner.service_endpoint() + } + + fn kind(&self) -> TransportKind { + self.inner.kind() + } + + fn route(&self) -> TransportRoute { + self.inner.route() + } + + async fn open(&self) -> Result, TransportError> { + let inner = self.inner.open().await?; + let log = Log::default(); + self.streams + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(log.clone()); + Ok(Box::new(Recording { inner, log })) + } + + async fn reroute(&self, endpoint: NetworkEndpoint) -> Result, TransportError> { + self.inner.reroute(endpoint).await + } + + async fn explain_closed_stream(&self) -> Option { + None + } +} + +struct Recording { + inner: Box, + log: Log, +} + +impl AsyncRead for Recording { + fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for Recording { + fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { + let written = Pin::new(&mut self.inner).poll_write(cx, buf); + if let Poll::Ready(Ok(count)) = &written + && let Some(bytes) = buf.get(..*count) + { + self.log + .lock() + .unwrap_or_else(PoisonError::into_inner) + .extend_from_slice(bytes); + } + written + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +fn length(bytes: &[u8]) -> Option { + let prefix: [u8; 4] = bytes.get(..4)?.try_into().ok()?; + usize::try_from(u32::from_be_bytes(prefix)).ok() +} + +/// The frontend messages a client wrote, as type and body. The startup +/// packet has no type byte; every message after it has one. +fn messages(written: &[u8]) -> Vec<(u8, Vec)> { + let mut rest = length(written) + .and_then(|startup| written.get(startup..)) + .unwrap_or_default(); + let mut parsed = Vec::new(); + while let Some((&kind, tail)) = rest.split_first() { + let Some(len) = length(tail) else { break }; + let (Some(body), Some(next)) = (tail.get(4..len), tail.get(len..)) else { + break; + }; + parsed.push((kind, body.to_vec())); + rest = next; + } + parsed +} + +fn query_starting(message: &(u8, Vec), text: &str) -> bool { + message.0 == b'Q' && message.1.starts_with(text.as_bytes()) +} + +/// The Parse messages of the last read-only flight on a stream: those +/// sent before its COMMIT, and those sent after it. +fn last_read_flight(messages: &[(u8, Vec)]) -> Option<(usize, usize)> { + let start = messages + .iter() + .rposition(|message| query_starting(message, "START TRANSACTION READ ONLY"))?; + let flight = messages.get(start..)?; + let commit = flight.iter().position(|message| query_starting(message, "COMMIT"))?; + let parses = |part: &[(u8, Vec)]| part.iter().filter(|(kind, _)| *kind == b'P').count(); + Some(( + parses(flight.get(..commit).unwrap_or_default()), + parses(flight.get(commit..).unwrap_or_default()), + )) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn table_definition_queries_all_run_before_the_commit() { + let fixture = start().await; + let liveness = test_liveness(); + let transport = Arc::new(RecordingTransport { + inner: direct(fixture.host(), fixture.port(), &liveness), + streams: Mutex::new(Vec::new()), + }); + let connection = connect_with(options_over( + transport.clone(), + password(&fixture.password_credentials()), + fixture.database(), + TlsConfig::disabled(), + WorkspaceSettings::workspace(false), + liveness, + )) + .await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE parent (id int PRIMARY KEY)", + "CREATE TABLE child (id int PRIMARY KEY, parent_id int REFERENCES parent (id))", + "CREATE INDEX child_parent ON child (parent_id)", + ], + ) + .await; + + connection + .fetch_table_definition(&TableRef::new(Some("public".to_owned()), "child"), within(CALL)) + .await + .expect("the definition"); + + let flights: Vec<(usize, usize)> = transport + .written() + .iter() + .filter_map(|written| last_read_flight(&messages(written))) + .collect(); + assert_eq!(flights.len(), 1, "{flights:?}"); + let (inside, after) = flights.first().copied().unwrap_or_default(); + assert!(inside > 1, "the definition read sent {inside} queries in its flight"); + assert_eq!(after, 0, "{after} queries went out after the COMMIT"); + connection.close().await.expect("a close"); +} diff --git a/linux/crates/drivers/postgres/tests/integration/main.rs b/linux/crates/drivers/postgres/tests/integration/main.rs index e4e3f54539..18c41628ce 100644 --- a/linux/crates/drivers/postgres/tests/integration/main.rs +++ b/linux/crates/drivers/postgres/tests/integration/main.rs @@ -11,6 +11,7 @@ mod conformance_engine; mod decode; mod editor_session; mod filter; +mod flight; mod liveness; mod metadata; mod pgbouncer; From e819a86a3c9e1b0f0d616d5a39f0ed59ab1034a4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:29:02 +0700 Subject: [PATCH 018/178] fix(linux-postgres): keep user SQL in stream_read from writing --- .../crates/drivers/postgres/src/connection.rs | 2 +- .../crates/drivers/postgres/src/editor/run.rs | 32 +-------- linux/crates/drivers/postgres/src/errors.rs | 32 +++++++++ .../crates/drivers/postgres/src/standalone.rs | 9 +++ .../drivers/postgres/src/stream_read.rs | 55 +++++++++----- .../postgres/tests/integration/read_only.rs | 72 ++++++++++++++++++- 6 files changed, 151 insertions(+), 51 deletions(-) diff --git a/linux/crates/drivers/postgres/src/connection.rs b/linux/crates/drivers/postgres/src/connection.rs index 4e4d95a49c..8f1486f338 100644 --- a/linux/crates/drivers/postgres/src/connection.rs +++ b/linux/crates/drivers/postgres/src/connection.rs @@ -362,7 +362,7 @@ impl Connection for PgConnection { return (link, Err(error)); } let result = match link.client() { - Ok(client) => stream(client, &read, sink.as_mut()).await, + Ok(client) => stream(client, link.standard_conforming_strings(), &read, sink.as_mut()).await, Err(error) => Err(error), }; (link, result) diff --git a/linux/crates/drivers/postgres/src/editor/run.rs b/linux/crates/drivers/postgres/src/editor/run.rs index 5a7c71a95e..86c1bd2050 100644 --- a/linux/crates/drivers/postgres/src/editor/run.rs +++ b/linux/crates/drivers/postgres/src/editor/run.rs @@ -5,8 +5,7 @@ use futures_util::StreamExt; use tablepro_core::sql_syntax::SqlGrammar; use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptToken}; use tablepro_core::{ - DriverError, ReadOnlyRefusal, ResultEvent, ResultSink, RowLimit, RunEnd, ServerCode, ServerNotice, SinkFlow, - Truncation, Value, + DriverError, ResultEvent, ResultSink, RowLimit, RunEnd, ServerCode, ServerNotice, SinkFlow, Truncation, Value, }; use tokio_postgres::SimpleQueryMessage; @@ -20,7 +19,7 @@ use super::stop_watch::{Interrupt, StopWatch}; use super::transaction_tracker::{KnownState, Next, PROBE_RELEASE, PROBE_SAVEPOINT, probe_answer}; use crate::cancel::acknowledges_stop; use crate::catalog::text_column; -use crate::errors::{map_error, sqlstate}; +use crate::errors::{map_error, read_only_refusal, sqlstate}; /// Rows per `ResultEvent::Rows`. The sink is awaited per batch, so a slow /// grid slows the read instead of growing a queue. @@ -403,18 +402,6 @@ fn end_after_stop(interrupt: Interrupt, error: &DriverError) -> RunEnd { } } -/// Inside the read-only wrapper, a statement that cannot run in a -/// transaction block at all (VACUUM, CREATE DATABASE) fails with 25001, -/// which here means the read-only rule refused it. -fn read_only_refusal(error: DriverError) -> DriverError { - match error { - DriverError::Server(diagnostics) if diagnostics.sqlstate() == Some("25001") => { - DriverError::ReadOnly(ReadOnlyRefusal::ServerRejected(diagnostics)) - } - other => other, - } -} - impl SessionCore { fn standard_conforming_strings(&self) -> bool { self.conforming.load(Ordering::SeqCst) @@ -423,7 +410,7 @@ impl SessionCore { #[cfg(test)] mod tests { - use tablepro_core::{ServerDiagnostics, TimeoutPhase}; + use tablepro_core::TimeoutPhase; use super::*; @@ -450,17 +437,4 @@ mod tests { } ); } - - #[test] - fn vacuum_in_the_read_only_wrapper_is_a_read_only_refusal() { - let vacuum = DriverError::reported(ServerDiagnostics::new( - Some(ServerCode::SqlState("25001".to_owned())), - "VACUUM cannot run inside a transaction block", - )); - - assert!(matches!( - read_only_refusal(vacuum), - DriverError::ReadOnly(ReadOnlyRefusal::ServerRejected(_)) - )); - } } diff --git a/linux/crates/drivers/postgres/src/errors.rs b/linux/crates/drivers/postgres/src/errors.rs index 5a6fad90e8..d014a168a7 100644 --- a/linux/crates/drivers/postgres/src/errors.rs +++ b/linux/crates/drivers/postgres/src/errors.rs @@ -125,6 +125,19 @@ pub(crate) fn sqlstate(error: &DriverError) -> Option<&str> { } } +/// Inside a read-only transaction the driver opened, a statement that +/// cannot run in a transaction block at all (VACUUM, CREATE DATABASE), +/// or one that tries to make it writable, fails with 25001, which there +/// means the read-only rule refused it. +pub(crate) fn read_only_refusal(error: DriverError) -> DriverError { + match error { + DriverError::Server(diagnostics) if diagnostics.sqlstate() == Some("25001") => { + DriverError::ReadOnly(ReadOnlyRefusal::ServerRejected(diagnostics)) + } + other => other, + } +} + /// What a pool refusal means. A wait that runs out means every slot is /// busy, which is the server having no room rather than a dead link. pub(crate) fn map_pool_error(error: &PoolError) -> DriverError { @@ -238,6 +251,25 @@ mod tests { assert_eq!(sqlstate(&DriverError::Busy), None); } + #[test] + fn a_25001_in_a_read_only_transaction_is_a_read_only_refusal() { + let refused = [ + "VACUUM cannot run inside a transaction block", + "transaction read-write mode must be set before any query", + ]; + for message in refused { + assert!( + matches!( + read_only_refusal(map_diagnostics(diagnostics("25001", message))), + DriverError::ReadOnly(ReadOnlyRefusal::ServerRejected(_)) + ), + "{message}" + ); + } + let other = map_diagnostics(diagnostics("42P01", "relation does not exist")); + assert_eq!(read_only_refusal(other.clone()), other); + } + #[test] fn a_pool_wait_that_runs_out_is_busy() { assert_eq!( diff --git a/linux/crates/drivers/postgres/src/standalone.rs b/linux/crates/drivers/postgres/src/standalone.rs index 01efef7935..5bf59de050 100644 --- a/linux/crates/drivers/postgres/src/standalone.rs +++ b/linux/crates/drivers/postgres/src/standalone.rs @@ -17,6 +17,7 @@ pub(crate) struct Standalone { client: Option, driving: Option>, abandon: Arc, + conforming: bool, } impl Standalone { @@ -25,6 +26,7 @@ impl Standalone { client: None, driving: None, abandon: Arc::new(AtomicBool::new(false)), + conforming: true, } } @@ -44,6 +46,7 @@ impl Standalone { opened = endpoint.open(&never) => opened?, }; let (client, connection) = opened; + self.conforming = connection.parameter("standard_conforming_strings") != Some("off"); stop.arm(client.cancel_token()); self.driving = Some(tracker.spawn(async move { if let Err(error) = connection.await { @@ -57,6 +60,12 @@ impl Standalone { Ok(()) } + /// The session's `standard_conforming_strings` as the login reported + /// it. + pub(crate) fn standard_conforming_strings(&self) -> bool { + self.conforming + } + pub(crate) fn client(&self) -> Result<&Client, DriverError> { self.client .as_ref() diff --git a/linux/crates/drivers/postgres/src/stream_read.rs b/linux/crates/drivers/postgres/src/stream_read.rs index db4d2749f9..714d882ba8 100644 --- a/linux/crates/drivers/postgres/src/stream_read.rs +++ b/linux/crates/drivers/postgres/src/stream_read.rs @@ -13,26 +13,31 @@ use tokio_postgres::{Client, SimpleQueryMessage}; use crate::catalog::{result_column, text_column}; use crate::codec::typed; use crate::editor::statement_class::{StatementClass, classify}; -use crate::errors::map_error; +use crate::errors::{map_error, read_only_refusal}; use crate::workspace_call::{decode_row, describe}; const BATCH_ROWS: usize = 256; +/// The read's transaction. Its SELECT takes the snapshot, after which +/// the server refuses SET TRANSACTION READ WRITE and any other way of +/// making the transaction writable, so user SQL cannot write in it. +const BEGIN: &str = "START TRANSACTION READ ONLY; SET LOCAL statement_timeout = 0; SELECT 1"; + /// Every row of a read, into the sink, inside a read-only transaction /// with no server timeout: an export can take minutes, and the caller's -/// deadline and cancel still stop it. +/// deadline and cancel still stop it. `conforming` is the session's +/// `standard_conforming_strings`, which decides how the server splits +/// the user's SQL. pub(crate) async fn stream( client: &Client, + conforming: bool, read: &ReadStatement, sink: &mut dyn ResultSink, ) -> Result { if let ReadStatement::User(sql) = read { - refuse_transaction_control(sql.as_str())?; + refuse_transaction_control(sql.as_str(), conforming)?; } - client - .batch_execute("START TRANSACTION READ ONLY; SET LOCAL statement_timeout = 0") - .await - .map_err(|error| map_error(&error))?; + client.batch_execute(BEGIN).await.map_err(|error| map_error(&error))?; let mut emitter = Emitter { sink, batch: Vec::new(), @@ -56,20 +61,21 @@ pub(crate) async fn stream( } }; let committed = client.batch_execute("COMMIT").await.map_err(|error| map_error(&error)); - streamed?; + streamed.map_err(read_only_refusal)?; committed?; Ok(StreamSummary::new(emitter.rows)) } /// The read-only transaction is the export's whole guarantee, so user /// SQL that would end it, or a routine that may, is refused before -/// anything is sent. -fn refuse_transaction_control(sql: &str) -> Result<(), DriverError> { +/// anything is sent. The server reads the whole text before it runs any +/// of it, so the split is the one the session's setting gives. +fn refuse_transaction_control(sql: &str, conforming: bool) -> Result<(), DriverError> { let plan = ScriptPlan::build( sql, SqlGrammar::PostgreSql, LexicalSettings { - backslash_escapes: false, + backslash_escapes: !conforming, }, ); for statement in plan.statements() { @@ -185,17 +191,28 @@ impl Emitter<'_> { mod tests { use super::*; + fn guarded(sql: &str, conforming: bool) -> bool { + matches!( + refuse_transaction_control(sql, conforming), + Err(DriverError::ReadOnly(ReadOnlyRefusal::ClientGuard)) + ) + } + #[test] fn user_sql_that_would_end_the_read_only_transaction_is_refused() { - assert!(refuse_transaction_control("SELECT 1; SELECT 2").is_ok()); + assert!(refuse_transaction_control("SELECT 1; SELECT 2", true).is_ok()); for sql in ["COMMIT; DELETE FROM t", "SELECT 1; ROLLBACK", "CALL p()", "BEGIN"] { - assert!( - matches!( - refuse_transaction_control(sql), - Err(DriverError::ReadOnly(ReadOnlyRefusal::ClientGuard)) - ), - "{sql}" - ); + assert!(guarded(sql, true), "{sql}"); } } + + /// With `standard_conforming_strings` off the server reads `\'` as an + /// escaped quote, so the COMMIT here is a statement of its own. + #[test] + fn the_guard_splits_the_way_a_non_conforming_server_does() { + let sql = "SELECT 'a\\', '; COMMIT; DELETE FROM t; SELECT '1'"; + + assert!(guarded(sql, false), "{sql}"); + assert!(!guarded(sql, true), "{sql}"); + } } diff --git a/linux/crates/drivers/postgres/tests/integration/read_only.rs b/linux/crates/drivers/postgres/tests/integration/read_only.rs index 070a62a3b7..6fccc5e06c 100644 --- a/linux/crates/drivers/postgres/tests/integration/read_only.rs +++ b/linux/crates/drivers/postgres/tests/integration/read_only.rs @@ -1,9 +1,14 @@ //! Read-only is held per transaction, so no statement escapes it and no //! setting outlives it. -use tablepro_core::{DriverError, ReadOnlyRefusal, RowLimit, RunEnd, TransactionState, Value, WriteKind}; +use tablepro_core::{ + Connection, DriverError, ReadOnlyRefusal, ReadStatement, RowLimit, RunEnd, StreamSummary, TransactionState, + UserSql, Value, WriteKind, +}; -use crate::support::{CALL, apply, connect, run, run_ok, scalar, session, setup, start, statement, within}; +use crate::support::{ + CALL, EventSink, Events, apply, connect, run, run_ok, scalar, session, setup, start, statement, within, +}; fn refused_by_server(end: &RunEnd) -> bool { matches!( @@ -127,3 +132,66 @@ async fn query_bound_nextval_is_25006() { outcome.map(|result| result.rows) ); } + +async fn stream_user_sql(connection: &dyn Connection, sql: &str) -> Result { + connection + .stream_read( + &ReadStatement::User(UserSql::new(sql)), + Box::new(EventSink::new(&Events::default())), + within(CALL), + ) + .await +} + +/// The read's own transaction has taken its snapshot before user SQL +/// runs, so none of these can make it writable. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn stream_read_user_sql_cannot_turn_the_transaction_writable() { + let fixture = start().await; + let owner = connect(&fixture, false).await; + setup(owner.as_ref(), &["CREATE TABLE t (id int)", "INSERT INTO t VALUES (1)"]).await; + + for sql in [ + "SET TRANSACTION READ WRITE; DELETE FROM t", + "SET transaction_read_only = off; DELETE FROM t", + "SET LOCAL transaction_read_only TO false; DELETE FROM t", + "SELECT set_config('transaction_read_only', 'off', true); DELETE FROM t", + "SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE; DELETE FROM t", + ] { + let outcome = stream_user_sql(owner.as_ref(), sql).await; + + assert!( + matches!(outcome, Err(DriverError::ReadOnly(ReadOnlyRefusal::ServerRejected(_)))), + "{sql}: {outcome:?}" + ); + } + assert_eq!(scalar(owner.as_ref(), "SELECT count(*) FROM t").await, Value::Int(1)); +} + +/// With `standard_conforming_strings` off the server reads `\'` as an +/// escaped quote, so the COMMIT is a statement of its own and the guard +/// has to see it that way too. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn stream_read_guard_splits_as_a_non_conforming_session_does() { + let fixture = start().await; + let owner = connect(&fixture, false).await; + setup( + owner.as_ref(), + &[ + "CREATE TABLE t (id int)", + "INSERT INTO t VALUES (1)", + "ALTER ROLE CURRENT_USER SET standard_conforming_strings = off", + ], + ) + .await; + + let outcome = stream_user_sql(owner.as_ref(), "SELECT 'a\\', '; COMMIT; DELETE FROM t; SELECT '1'").await; + + assert!( + matches!(outcome, Err(DriverError::ReadOnly(ReadOnlyRefusal::ClientGuard))), + "{outcome:?}" + ); + assert_eq!(scalar(owner.as_ref(), "SELECT count(*) FROM t").await, Value::Int(1)); +} From 681075a47e922a27b302e1ed4f2553943b6a86b4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:29:07 +0700 Subject: [PATCH 019/178] fix(linux-mysql): refuse a TLS name sqlx cannot verify over TCP --- linux/crates/drivers/mysql/src/physical.rs | 161 ++++++++++++++++++++- 1 file changed, 156 insertions(+), 5 deletions(-) diff --git a/linux/crates/drivers/mysql/src/physical.rs b/linux/crates/drivers/mysql/src/physical.rs index ae20b92460..4558e1a208 100644 --- a/linux/crates/drivers/mysql/src/physical.rs +++ b/linux/crates/drivers/mysql/src/physical.rs @@ -48,6 +48,7 @@ pub(crate) struct MysqlEndpoint { transport: Arc, options: MySqlConnectOptions, tls_server_name: String, + verifies_name: bool, liveness: LivenessPolicy, } @@ -102,6 +103,7 @@ impl MysqlEndpoint { transport: target.transport.clone(), options, tls_server_name: target.tls_server_name().to_owned(), + verifies_name: target.tls.mode == TlsMode::VerifyFull, liveness, }) } @@ -117,15 +119,26 @@ impl MysqlEndpoint { /// The options for one login over the transport's route. Over a /// socket the `host` is not dialled, so it carries the name TLS /// verifies: the service's own, never the forward's. - fn options_for(&self, kind: SessionKind) -> MySqlConnectOptions { + /// + /// Over TCP, sqlx verifies the certificate against the host it dials + /// and takes no other name, so a route whose name differs is refused + /// rather than checked against the wrong one. + fn options_for(&self, kind: SessionKind) -> Result { let options = match self.transport.route() { - TransportRoute::Tcp(endpoint) => self.options.clone().host(endpoint.host()).port(endpoint.port()), + TransportRoute::Tcp(endpoint) => { + if self.verifies_name && endpoint.host() != self.tls_server_name { + return Err(DriverError::Config(ConfigError::ServerNameOverrideUnsupported { + route: self.transport.kind().class(), + })); + } + self.options.clone().host(endpoint.host()).port(endpoint.port()) + } TransportRoute::UnixSocket(path) => self.options.clone().socket(path).host(&self.tls_server_name), }; - match kind { + Ok(match kind { SessionKind::Workspace => options.timezone(Some(WORKSPACE_TIME_ZONE.to_owned())), SessionKind::Editor | SessionKind::Control => options, - } + }) } /// Log in and set the session up, within the connect and login @@ -152,7 +165,7 @@ impl MysqlEndpoint { } async fn open(&self, kind: SessionKind, limits: &SessionLimits) -> Result { - let options = self.options_for(kind); + let options = self.options_for(kind)?; let mut connection = match options.connect().await { Ok(connection) => connection, Err(error) => return Err(self.explain(map_connect_error(&error, self.service())).await), @@ -253,3 +266,141 @@ async fn identify(connection: &mut MySqlConnection, endpoint: &NetworkEndpoint) .map_err(|_| DriverError::Protocol(format!("CONNECTION_ID() returned {id:?}")))?; Ok((connection_id, text(1)?)) } + +#[cfg(test)] +mod tests { + use async_trait::async_trait; + use secrecy::SecretString; + use tablepro_core::{TlsConfig, TransportClass, TransportError, TransportKind, TransportStream}; + + use super::*; + + /// A route whose dial target is not the service, as a forward that + /// listens on a local port would be. + #[derive(Debug)] + struct Route { + service: NetworkEndpoint, + kind: TransportKind, + route: TransportRoute, + } + + #[async_trait] + impl Transport for Route { + fn service_endpoint(&self) -> &NetworkEndpoint { + &self.service + } + + fn kind(&self) -> TransportKind { + self.kind.clone() + } + + fn route(&self) -> TransportRoute { + self.route.clone() + } + + async fn open(&self) -> Result, TransportError> { + Err(TransportError::Refused { + endpoint: self.service.clone(), + }) + } + + async fn reroute(&self, _endpoint: NetworkEndpoint) -> Result, TransportError> { + Err(TransportError::Refused { + endpoint: self.service.clone(), + }) + } + + async fn explain_closed_stream(&self) -> Option { + None + } + } + + fn endpoint(host: &str) -> NetworkEndpoint { + NetworkEndpoint::new(host, 3306).unwrap() + } + + fn mysql_endpoint(route: Route, server_name: Option<&str>) -> MysqlEndpoint { + let target = NetworkTarget { + transport: Arc::new(route), + credentials: Credentials::Password { + username: "app".to_owned(), + password: SecretString::from("secret"), + }, + database: Some("app".to_owned()), + tls: TlsConfig { + server_name: server_name.map(str::to_owned), + ..TlsConfig::verify_full() + }, + }; + MysqlEndpoint::new(&target, LivenessPolicy::DESKTOP).unwrap() + } + + #[test] + fn a_direct_route_verifies_the_host_it_dials() { + let direct = mysql_endpoint( + Route { + service: endpoint("db.prod"), + kind: TransportKind::DirectTcp, + route: TransportRoute::Tcp(endpoint("db.prod")), + }, + None, + ); + + let options = direct.options_for(SessionKind::Editor).unwrap(); + + assert_eq!(options.get_host(), "db.prod"); + assert_eq!(options.get_socket(), None); + } + + /// sqlx would check the certificate against 127.0.0.1, not the name + /// the user gave, so the login is refused before it is attempted. + #[test] + fn a_tcp_route_to_another_host_than_the_name_is_refused() { + let forwarded = |server_name| { + mysql_endpoint( + Route { + service: endpoint("db.prod"), + kind: TransportKind::SshForward { + destination: "bastion".to_owned(), + }, + route: TransportRoute::Tcp(endpoint("127.0.0.1")), + }, + server_name, + ) + }; + + for server_name in [None, Some("db.internal")] { + assert!( + matches!( + forwarded(server_name).options_for(SessionKind::Workspace), + Err(DriverError::Config(ConfigError::ServerNameOverrideUnsupported { + route: TransportClass::SshForward + })) + ), + "{server_name:?}" + ); + } + } + + #[test] + fn a_socket_route_carries_the_name_tls_verifies() { + let socket = mysql_endpoint( + Route { + service: endpoint("db.prod"), + kind: TransportKind::SshForward { + destination: "bastion".to_owned(), + }, + route: TransportRoute::UnixSocket("/run/user/1000/forward.sock".into()), + }, + Some("db.internal"), + ); + + let options = socket.options_for(SessionKind::Control).unwrap(); + + assert_eq!(options.get_host(), "db.internal"); + assert_eq!( + options.get_socket().map(|path| path.as_path()), + Some(std::path::Path::new("/run/user/1000/forward.sock")) + ); + } +} From 8972e224ce91035d7f779b4201aefeb167d9abca Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:29:07 +0700 Subject: [PATCH 020/178] fix(linux-mysql): stop the editor's batch when its reader goes away --- linux/crates/drivers/mysql/src/editor_run.rs | 21 ++++++- .../drivers/mysql/src/editor_session.rs | 37 ++++++----- .../mysql/tests/integration/editor_session.rs | 61 ++++++++++++++++++- .../mysql/tests/integration/support.rs | 23 ++++++- 4 files changed, 120 insertions(+), 22 deletions(-) diff --git a/linux/crates/drivers/mysql/src/editor_run.rs b/linux/crates/drivers/mysql/src/editor_run.rs index 61e0dc82c0..dab336105b 100644 --- a/linux/crates/drivers/mysql/src/editor_run.rs +++ b/linux/crates/drivers/mysql/src/editor_run.rs @@ -15,6 +15,7 @@ use tablepro_core::column::ResultColumn; use tablepro_core::sql_syntax::SqlGrammar; use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptTokenKind}; use tablepro_core::{DriverError, ResultEvent, ResultSink, RowLimit, SinkFlow, Truncation, Value}; +use tokio_util::sync::CancellationToken; use crate::catalog::result_column; use crate::decode::decode_row; @@ -47,12 +48,17 @@ pub(crate) fn batches(sql: &str) -> Vec<&str> { plan.statements().iter().map(|statement| statement.text(sql)).collect() } +/// Run the script. When the sink stops reading, `consumer_gone` is +/// cancelled so the caller can stop the server. The batch already sent +/// is still read to its end, unshown, since only that frees the +/// connection, and no later batch is sent. pub(crate) async fn run_script( connection: &mut MySqlConnection, flavour: MySqlFlavour, sql: &str, limit: RowLimit, sink: &mut dyn ResultSink, + consumer_gone: &CancellationToken, ) -> Result { let mut emitter = Emitter { sink, @@ -62,6 +68,9 @@ pub(crate) async fn run_script( gone: false, }; for text in batches(sql) { + if emitter.gone { + break; + } let results = connection.fetch_many(sqlx::raw_sql(AssertSqlSafe(text.to_owned()))); let mut results = std::pin::pin!(results); while let Some(item) = results.try_next().await.map_err(|error| map_error(&error, flavour))? { @@ -70,11 +79,13 @@ pub(crate) async fn run_script( Either::Left(done) => emitter.done(done.rows_affected()).await, } if emitter.gone { - return Ok(RunOutcome { consumer_gone: true }); + consumer_gone.cancel(); } } } - Ok(RunOutcome::default()) + Ok(RunOutcome { + consumer_gone: emitter.gone, + }) } struct OpenSet { @@ -93,6 +104,9 @@ struct Emitter<'a> { impl Emitter<'_> { async fn row(&mut self, row: &MySqlRow) { + if self.gone { + return; + } if self.open.is_none() { let columns: Arc<[ResultColumn]> = row .columns() @@ -125,6 +139,9 @@ impl Emitter<'_> { } async fn done(&mut self, rows_affected: u64) { + if self.gone { + return; + } let Some(set) = self.open.take() else { self.send(ResultEvent::CommandCompleted { rows_affected: Some(rows_affected), diff --git a/linux/crates/drivers/mysql/src/editor_session.rs b/linux/crates/drivers/mysql/src/editor_session.rs index 4b9568f816..5a0150a5e0 100644 --- a/linux/crates/drivers/mysql/src/editor_session.rs +++ b/linux/crates/drivers/mysql/src/editor_session.rs @@ -82,9 +82,10 @@ impl MysqlEditorSession { })) } - /// Run the script, stopping it on a cancel, at the deadline or when - /// the session closes. A stop is KILL QUERY on a connection of the - /// session's own; the statement then has the cancel grace to end. + /// Run the script, stopping it on a cancel, at the deadline, when + /// the session closes or when the sink stops reading. A stop is KILL + /// QUERY on a connection of the session's own; the statement then + /// has the cancel grace to end. async fn supervise( &self, held: &mut Held, @@ -97,26 +98,32 @@ impl MysqlEditorSession { return finished(Err(error)).map(|end| (end, true)); } let deadline = options.deadline.map(|limit| Instant::now() + limit); - let running = run_script(&mut held.connection, self.flavour, sql.as_str(), limit, sink); + let consumer_gone = CancellationToken::new(); + let running = run_script( + &mut held.connection, + self.flavour, + sql.as_str(), + limit, + sink, + &consumer_gone, + ); let mut running = std::pin::pin!(running); let stop = tokio::select! { outcome = &mut running => return finished(outcome).map(|end| (end, true)), () = options.cancel.cancelled() => Stop::Cancelled, () = self.closing.cancelled() => Stop::Cancelled, + () = consumer_gone.cancelled() => Stop::Cancelled, () = elapsed(deadline) => Stop::DeadlineElapsed, }; let until = Instant::now() + self.liveness.cancel_grace; - let killing = self.control.kill_query(self.connection_id); - let mut killing = std::pin::pin!(killing); - let mut delivered = None; - let settled = loop { - tokio::select! { - biased; - sent = &mut killing, if delivered.is_none() => delivered = Some(sent.is_ok()), - outcome = &mut running => break Some(outcome), - () = tokio::time::sleep_until(until) => break None, - } - }; + // Nothing more is read until the KILL has landed. A batch the + // server is still sending then waits on the socket, so it cannot + // move on to its next statement before the KILL stops it. + let delivered = tokio::time::timeout_at(until, self.control.kill_query(self.connection_id)) + .await + .ok() + .map(|sent| sent.is_ok()); + let settled = tokio::time::timeout_at(until, &mut running).await.ok(); let server_cancelled = delivered == Some(true); let end = match stop { Stop::Cancelled => RunEnd::Cancelled, diff --git a/linux/crates/drivers/mysql/tests/integration/editor_session.rs b/linux/crates/drivers/mysql/tests/integration/editor_session.rs index cb3652e421..ecb850891d 100644 --- a/linux/crates/drivers/mysql/tests/integration/editor_session.rs +++ b/linux/crates/drivers/mysql/tests/integration/editor_session.rs @@ -1,10 +1,25 @@ //! Where an editor session stands with a transaction, asked of the -//! server: MariaDB's @@in_transaction, MySQL's performance_schema. +//! server: MariaDB's @@in_transaction, MySQL's performance_schema. And +//! what a run does when its reader goes away. -use tablepro_core::{CallOptions, EditorSession, TransactionState}; +use tablepro_core::{ + CallOptions, EditorSession, EditorSessionState, RowLimit, RunEnd, RunOptions, TransactionState, UserSql, +}; use tablepro_test_fixtures::MysqlFlavour; +use tokio_util::sync::CancellationToken; -use crate::support::{CALL, connect, connect_root, run_ok, session, start, within}; +use crate::support::{ + CALL, EventSink, Events, FLAVOURS, connect, connect_root, count, run_ok, session, setup, start, within, +}; + +/// A hundred thousand rows of a kilobyte each: far more than the socket +/// buffers hold, so the server is still sending the SELECT when the +/// reader leaves. The DELETE behind it must never run. +const WIDE_THEN_DELETE: &str = "WITH d AS (SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 \ + UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 \ + UNION ALL SELECT 9) \ + SELECT REPEAT('x', 1000) AS filler FROM d a, d b, d c, d e, d f; \ + DELETE FROM t"; async fn state_after(session: &dyn EditorSession, sql: &str) -> TransactionState { let (summary, _) = crate::support::run(session, sql).await; @@ -73,3 +88,43 @@ async fn mysql_user_without_ps_select_unknown() { editor.close().await; connection.close().await.expect("a close"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn consumer_gone_kills_the_rest_of_the_batch() { + for flavour in FLAVOURS { + let fixture = start(flavour).await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &["CREATE TABLE t (id int PRIMARY KEY)", "INSERT INTO t VALUES (1)"], + ) + .await; + let editor = session(connection.as_ref()).await; + let events = Events::default(); + let options = + RunOptions::new(CancellationToken::new()).with_row_limit(RowLimit::new(1_000_000).expect("a limit")); + + let summary = editor + .run( + UserSql::new(WIDE_THEN_DELETE), + options, + Box::new(EventSink::new(&events).gone_after(1)), + ) + .await + .expect("the run"); + + assert_eq!(summary.end, RunEnd::Cancelled, "{flavour:?}"); + assert_eq!(editor.state(), EditorSessionState::Ready, "{flavour:?}"); + // The next run reads whatever the batch left on the wire first, + // so by now a DELETE that was still coming has run. + run_ok(editor.as_ref(), "SELECT 1").await; + assert_eq!( + count(connection.as_ref(), "SELECT COUNT(*) FROM t").await, + 1, + "{flavour:?}" + ); + editor.close().await; + connection.close().await.expect("a close"); + } +} diff --git a/linux/crates/drivers/mysql/tests/integration/support.rs b/linux/crates/drivers/mysql/tests/integration/support.rs index edf1d3333d..83a11ad6f8 100644 --- a/linux/crates/drivers/mysql/tests/integration/support.rs +++ b/linux/crates/drivers/mysql/tests/integration/support.rs @@ -258,19 +258,38 @@ impl Events { pub struct EventSink { events: Events, + gone_after_rows: Option, + seen_rows: usize, } impl EventSink { pub fn new(events: &Events) -> Self { - Self { events: events.clone() } + Self { + events: events.clone(), + gone_after_rows: None, + seen_rows: 0, + } + } + + /// Stop reading once this many rows have arrived, as a closed tab + /// does. + pub fn gone_after(mut self, rows: usize) -> Self { + self.gone_after_rows = Some(rows); + self } } #[async_trait] impl ResultSink for EventSink { async fn accept(&mut self, event: ResultEvent) -> SinkFlow { + if let ResultEvent::Rows(rows) = &event { + self.seen_rows += rows.len(); + } self.events.0.lock().unwrap_or_else(PoisonError::into_inner).push(event); - SinkFlow::Continue + match self.gone_after_rows { + Some(limit) if self.seen_rows >= limit => SinkFlow::ConsumerGone, + _ => SinkFlow::Continue, + } } } From e6f985d4d49c45a793b2a0b92daa780134555989 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:29:08 +0700 Subject: [PATCH 021/178] fix(linux-mysql): prepare every save step so a ';' cannot add one --- linux/crates/drivers/mysql/src/errors.rs | 3 ++ linux/crates/drivers/mysql/src/write_batch.rs | 29 +++++------ .../drivers/mysql/tests/integration/main.rs | 1 + .../mysql/tests/integration/write_batch.rs | 49 +++++++++++++++++++ 4 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 linux/crates/drivers/mysql/tests/integration/write_batch.rs diff --git a/linux/crates/drivers/mysql/src/errors.rs b/linux/crates/drivers/mysql/src/errors.rs index 1f60d2226d..e922034a93 100644 --- a/linux/crates/drivers/mysql/src/errors.rs +++ b/linux/crates/drivers/mysql/src/errors.rs @@ -23,6 +23,9 @@ pub(crate) const READ_ONLY_TRANSACTION: u16 = 1792; /// ER_WARNING_NOT_COMPLETE_ROLLBACK: a table that cannot roll back was /// written inside the transaction. pub(crate) const INCOMPLETE_ROLLBACK: u16 = 1196; +/// ER_UNSUPPORTED_PS: the server parsed the statement and cannot run it +/// as a prepared statement. +pub(crate) const UNSUPPORTED_PS: u16 = 1295; /// MySQL's ER_QUERY_TIMEOUT for `max_execution_time`. pub(crate) const MYSQL_QUERY_TIMEOUT: u16 = 3024; /// MariaDB's ER_STATEMENT_TIMEOUT for `max_statement_time`. diff --git a/linux/crates/drivers/mysql/src/write_batch.rs b/linux/crates/drivers/mysql/src/write_batch.rs index ff7cbc4e48..1a249cb9fd 100644 --- a/linux/crates/drivers/mysql/src/write_batch.rs +++ b/linux/crates/drivers/mysql/src/write_batch.rs @@ -17,7 +17,7 @@ use tablepro_core::{ use crate::cancel::{MySqlStop, acknowledges_stop, interrupted}; use crate::decode::decode_row; -use crate::errors::{INCOMPLETE_ROLLBACK, map_error, reply_lost}; +use crate::errors::{INCOMPLETE_ROLLBACK, UNSUPPORTED_PS, map_error, reply_lost, server_number}; use crate::flavour::MySqlFlavour; use crate::mysql_call::Tainter; use crate::mysql_timestamp_mode::MySqlTimestampMode; @@ -261,21 +261,22 @@ async fn schema_changes(context: &mut WriteContext<'_>, plan: &WritePlan) -> Res outcome } -/// One step. A statement with parameters is prepared; one without goes -/// over the text protocol, which takes statements MySQL cannot prepare, -/// such as CREATE TRIGGER. +/// One step, prepared even without parameters: a prepare takes exactly +/// one statement, so a stray `;` in generated SQL fails to parse instead +/// of running a second one. Only a statement the server parsed whole +/// and cannot prepare, such as CREATE TRIGGER on MySQL, goes over the +/// text protocol, which runs every statement it is given. async fn execute(context: &mut WriteContext<'_>, statement: &Statement) -> Result { - let done = match statement.params().is_empty() { - true => { - context - .connection - .execute(sqlx::raw_sql(AssertSqlSafe(statement.sql().to_owned()))) - .await - } - false => prepared(statement)?.execute(&mut *context.connection).await, + let outcome = prepared(statement)?.execute(&mut *context.connection).await; + let done = match outcome.map_err(|error| map_error(&error, context.flavour)) { + Err(error) if statement.params().is_empty() && server_number(&error) == Some(UNSUPPORTED_PS) => context + .connection + .execute(sqlx::raw_sql(AssertSqlSafe(statement.sql().to_owned()))) + .await + .map_err(|error| map_error(&error, context.flavour))?, + other => other?, }; - done.map(|result| result.rows_affected()) - .map_err(|error| map_error(&error, context.flavour)) + Ok(done.rows_affected()) } async fn run(context: &mut WriteContext<'_>, sql: &str) -> Result<(), DriverError> { diff --git a/linux/crates/drivers/mysql/tests/integration/main.rs b/linux/crates/drivers/mysql/tests/integration/main.rs index 35ba8817c5..3db3ab525d 100644 --- a/linux/crates/drivers/mysql/tests/integration/main.rs +++ b/linux/crates/drivers/mysql/tests/integration/main.rs @@ -15,3 +15,4 @@ mod non_transactional; mod row_limit; mod support; mod tls; +mod write_batch; diff --git a/linux/crates/drivers/mysql/tests/integration/write_batch.rs b/linux/crates/drivers/mysql/tests/integration/write_batch.rs new file mode 100644 index 0000000000..ffac594d96 --- /dev/null +++ b/linux/crates/drivers/mysql/tests/integration/write_batch.rs @@ -0,0 +1,49 @@ +//! A save sends each statement on its own, so a stray `;` in one can +//! never run a second statement behind it. + +use tablepro_core::WriteKind; + +use crate::support::{FLAVOURS, apply, connect, count, setup, start}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_statement_after_a_semicolon_never_runs() { + for flavour in FLAVOURS { + let fixture = start(flavour).await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &["CREATE TABLE t (id int PRIMARY KEY)", "INSERT INTO t VALUES (1)"], + ) + .await; + + let smuggled = [ + (WriteKind::RowChanges, "INSERT INTO t VALUES (2); DELETE FROM t"), + (WriteKind::SchemaChanges, "CREATE TABLE u (id int); DROP TABLE t"), + // MySQL cannot prepare CREATE TRIGGER, but this text is two + // statements, so it never reaches the text protocol. + ( + WriteKind::SchemaChanges, + "CREATE TRIGGER t_same BEFORE INSERT ON t FOR EACH ROW SET NEW.id = NEW.id; DROP TABLE t", + ), + ]; + for (kind, sql) in smuggled { + let outcome = apply(connection.as_ref(), kind, &[sql]).await; + assert!(outcome.is_err(), "{flavour:?} `{sql}`: {outcome:?}"); + } + apply( + connection.as_ref(), + WriteKind::SchemaChanges, + &["CREATE TRIGGER t_same BEFORE INSERT ON t FOR EACH ROW SET NEW.id = NEW.id"], + ) + .await + .expect("one trigger, sent as one statement"); + + assert_eq!( + count(connection.as_ref(), "SELECT COUNT(*) FROM t").await, + 1, + "{flavour:?}" + ); + connection.close().await.expect("a close"); + } +} From ffbd3091bd7322df2909682b9af19ef05642698b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:29:17 +0700 Subject: [PATCH 022/178] fix(linux-editor): build a result page only when its line is shown --- linux/crates/app/src/ui/editor/mod.rs | 33 ++-- .../crates/app/src/ui/editor/results_pane.rs | 169 ++++++++++++++---- linux/crates/app/src/ui/editor/tests.rs | 24 +++ 3 files changed, 177 insertions(+), 49 deletions(-) diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index f72ddb4d1d..042fe38717 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -414,22 +414,25 @@ impl SqlEditor { /// Add the log's lines from `from` on to the results list. fn show_new_entries(&self, from: usize) { let imp = self.imp(); - let Some(grid_sender) = imp.grid_sender.get() else { - return; - }; let log = imp.log.borrow(); let Some(log) = log.as_ref() else { return; }; - for entry in log.entries().get(from..).unwrap_or_default() { - let weak = self.downgrade(); - let widget = result_widgets::entry_widget(entry, grid_sender, move |position| { - if let Some(editor) = weak.upgrade() { - editor.go_to_char(position); - } - }); - imp.results.append(entry, &widget); - } + imp.results.append(log.entries().get(from..).unwrap_or_default()); + } + + /// The page the results list shows for the log's line `index`. + fn result_page(&self, index: u32) -> Option { + let imp = self.imp(); + let grid_sender = imp.grid_sender.get()?; + let log = imp.log.borrow(); + let entry = log.as_ref()?.entries().get(usize::try_from(index).ok()?)?; + let weak = self.downgrade(); + Some(result_widgets::entry_widget(entry, grid_sender, move |position| { + if let Some(editor) = weak.upgrade() { + editor.go_to_char(position); + } + })) } fn finish_run(&self, query: String, metadata: &tablepro_session::ConnectionMetadata, started_at: SystemTime) { @@ -579,8 +582,12 @@ impl SqlEditor { imp.menu_button.set_icon_name(crate::ui::icons::VIEW_MORE); } - /// Context-menu actions from a result grid. + /// Result pages, and the context-menu actions from their grids. fn connect_grid(&self) { + let weak = self.downgrade(); + self.imp() + .results + .set_page_builder(move |index| weak.upgrade().and_then(|editor| editor.result_page(index))); let (sender, receiver) = relm4::channel::(); let _ = self.imp().grid_sender.set(sender); let weak = self.downgrade(); diff --git a/linux/crates/app/src/ui/editor/results_pane.rs b/linux/crates/app/src/ui/editor/results_pane.rs index 2f37b7601e..efddec4d90 100644 --- a/linux/crates/app/src/ui/editor/results_pane.rs +++ b/linux/crates/app/src/ui/editor/results_pane.rs @@ -7,8 +7,15 @@ use super::result_item::ResultItem; use super::run_log::{EntryKind, RunLogEntry}; use super::sidebar_policy::{self, SidebarState}; +/// How many results keep their page once shown, so going back to one +/// keeps its scroll position without every line holding a page. +const PAGES_KEPT: usize = 8; + +type PageBuilder = Box Option>; + mod imp { - use std::cell::Cell; + use std::cell::{Cell, RefCell}; + use std::collections::VecDeque; use libadwaita::subclass::prelude::*; @@ -34,6 +41,9 @@ mod imp { pub items: std::cell::OnceCell, pub selection: std::cell::OnceCell, pub sidebar: Cell, + pub page_builder: RefCell>, + /// The lines whose page is built, most recently shown first. + pub built: RefCell>, } #[glib::object_subclass] @@ -118,16 +128,10 @@ impl ResultsPane { if let Some(items) = imp.items.get() { items.remove_all(); } - let pages: Vec = imp - .stack - .pages() - .iter::() - .filter_map(Result::ok) - .filter(|page| page.name().is_none_or(|name| name != "empty")) - .map(|page| page.child()) - .collect(); - for child in pages { - imp.stack.remove(&child); + for index in imp.built.take() { + if let Some(page) = imp.stack.child_by_name(&page_name(index)) { + imp.stack.remove(&page); + } } imp.stack.set_visible_child_name("empty"); imp.sidebar.set(SidebarState::default()); @@ -136,6 +140,13 @@ impl ResultsPane { imp.transaction_label.set_visible(false); } + /// What builds a line's page. A page is built when its line is + /// first shown, so a script with thousands of statements builds + /// one, not thousands. + pub fn set_page_builder(&self, builder: impl Fn(u32) -> Option + 'static) { + self.imp().page_builder.replace(Some(Box::new(builder))); + } + pub fn set_running(&self, running: bool) { self.imp().spinner.set_visible(running); } @@ -150,16 +161,25 @@ impl ResultsPane { imp.transaction_label.set_visible(label.is_some()); } - /// Add a line and the widget it shows. - pub fn append(&self, entry: &RunLogEntry, widget: >k4::Widget) { + /// Add the lines for `entries`, which follow the ones listed. + pub fn append(&self, entries: &[RunLogEntry]) { let imp = self.imp(); let Some(items) = imp.items.get() else { return; }; - let index = items.n_items(); - let (title, icon) = entry_title(index as usize, entry); - items.append(&ResultItem::new(&title, &entry.preview, icon)); - imp.stack.add_named(widget, Some(&page_name(index))); + if entries.is_empty() { + return; + } + let first = items.n_items(); + let added: Vec = entries + .iter() + .enumerate() + .map(|(offset, entry)| { + let (title, icon) = entry_title(first as usize + offset, entry); + ResultItem::new(&title, &entry.preview, icon) + }) + .collect(); + items.splice(first, 0, &added); let previous = SidebarState { show_sidebar: imp.split_view.shows_sidebar(), @@ -169,7 +189,7 @@ impl ResultsPane { imp.sidebar.set(state); imp.sidebar_toggle.set_visible(state.toggle_visible); imp.split_view.set_show_sidebar(state.show_sidebar); - if index == 0 { + if first == 0 { self.select(0); } } @@ -196,7 +216,7 @@ impl ResultsPane { fn show_page(&self, index: u32) { let imp = self.imp(); - if index == gtk4::INVALID_LIST_POSITION { + if index == gtk4::INVALID_LIST_POSITION || !self.keep_page(index) { return; } imp.stack.set_visible_child_name(&page_name(index)); @@ -205,6 +225,37 @@ impl ResultsPane { imp.split_view.set_show_sidebar(false); } } + + /// Build the line's page unless it is kept already, letting go of + /// the page shown longest ago once more than `PAGES_KEPT` are kept. + fn keep_page(&self, index: u32) -> bool { + let imp = self.imp(); + { + let mut built = imp.built.borrow_mut(); + if let Some(position) = built.iter().position(|kept| *kept == index) { + built.remove(position); + built.push_front(index); + return true; + } + } + let page = imp.page_builder.borrow().as_ref().and_then(|build| build(index)); + let Some(page) = page else { + return false; + }; + imp.stack.add_named(&page, Some(&page_name(index))); + let evicted: Vec = { + let mut built = imp.built.borrow_mut(); + built.push_front(index); + let keep = built.len().min(PAGES_KEPT); + built.drain(keep..).collect() + }; + for oldest in evicted { + if let Some(page) = imp.stack.child_by_name(&page_name(oldest)) { + imp.stack.remove(&page); + } + } + true + } } fn page_name(index: u32) -> String { @@ -304,7 +355,14 @@ mod tests { fn pane() -> ResultsPane { crate::register_test_resources(); - ResultsPane::default() + let pane = ResultsPane::default(); + pane.set_page_builder(|index| Some(gtk4::Label::new(Some(&index.to_string())).upcast())); + pane + } + + fn built_pages(pane: &ResultsPane) -> u32 { + // The stack always holds the empty state as well. + pane.imp().stack.pages().n_items() - 1 } fn entry(kind: EntryKind) -> RunLogEntry { @@ -319,16 +377,12 @@ mod tests { entry(EntryKind::Command { rows_affected: Some(1) }) } - fn page() -> gtk4::Widget { - gtk4::Label::new(Some("page")).upcast() - } - #[gtk4::test] fn twenty_results_use_list_not_switcher() { let pane = pane(); for _ in 0..20 { - pane.append(&command(), &page()); + pane.append(&[command()]); } let imp = pane.imp(); @@ -342,7 +396,7 @@ mod tests { fn a_single_result_has_no_list() { let pane = pane(); - pane.append(&command(), &page()); + pane.append(&[command()]); assert!(!pane.imp().sidebar_toggle.is_visible()); assert!(!pane.imp().split_view.shows_sidebar()); @@ -351,15 +405,14 @@ mod tests { #[gtk4::test] fn first_failure_is_selected() { let pane = pane(); - pane.append(&command(), &page()); - pane.append( - &entry(EntryKind::Failed { + pane.append(&[command()]); + pane.append(&[ + entry(EntryKind::Failed { message: "boom".to_owned(), error_char: None, }), - &page(), - ); - pane.append(&entry(EntryKind::NotRun), &page()); + entry(EntryKind::NotRun), + ]); pane.select(1); @@ -373,7 +426,7 @@ mod tests { let imp = pane.imp(); imp.split_view.set_collapsed(true); for _ in 0..3 { - pane.append(&command(), &page()); + pane.append(&[command()]); } imp.split_view.set_show_sidebar(true); @@ -391,15 +444,16 @@ mod tests { #[gtk4::test] fn clearing_forgets_the_last_run() { let pane = pane(); - pane.append(&command(), &page()); - pane.append(&command(), &page()); + pane.append(&[command(), command()]); + pane.select(1); pane.clear(); assert_eq!(pane.n_entries(), 0); assert!(!pane.imp().sidebar_toggle.is_visible()); assert_eq!(pane.imp().stack.visible_child_name().as_deref(), Some("empty")); - pane.append(&command(), &page()); + assert_eq!(built_pages(&pane), 0, "a page outlived its run"); + pane.append(&[command()]); assert_eq!(pane.imp().stack.visible_child_name().as_deref(), Some("entry-0")); } @@ -412,4 +466,47 @@ mod tests { gtk4::AccessibleProperty::Label )); } + + #[gtk4::test] + fn a_long_list_builds_only_the_page_it_shows() { + let pane = pane(); + let not_run = entry(EntryKind::NotRun); + let entries = vec![not_run; 100_000]; + + pane.append(&entries); + + assert_eq!(pane.n_entries(), 100_000); + assert_eq!(built_pages(&pane), 1); + assert_eq!(pane.imp().stack.visible_child_name().as_deref(), Some("entry-0")); + } + + #[gtk4::test] + fn paging_through_the_list_keeps_a_bounded_number_of_pages() { + let pane = pane(); + pane.append(&vec![command(); 50]); + + for index in 0..50 { + pane.select(index); + } + + assert_eq!(built_pages(&pane), PAGES_KEPT as u32); + assert_eq!(pane.imp().stack.visible_child_name().as_deref(), Some("entry-49")); + assert!( + pane.imp().stack.child_by_name("entry-0").is_none(), + "the oldest page was kept" + ); + } + + #[gtk4::test] + fn going_back_to_a_recent_result_keeps_its_page() { + let pane = pane(); + pane.append(&[command(), command()]); + pane.select(0); + let first = pane.imp().stack.visible_child().expect("the first page"); + + pane.select(1); + pane.select(0); + + assert_eq!(pane.imp().stack.visible_child().as_ref(), Some(&first)); + } } diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 1ac2f4a6d9..6e05926ee6 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -133,6 +133,30 @@ fn a_script_fills_the_results_list_one_line_per_statement() { assert!(harness.editor.imp().run_button.is_sensitive()); } +#[gtk4::test] +fn a_long_script_that_fails_early_builds_one_result_page() { + let mut runs = Replies::default(); + runs.when_sql_contains( + "boom", + FakeRun::failed(DriverError::server("relation \"boom\" does not exist")), + ); + runs.always(FakeRun::completed(Vec::new())); + let script: Vec = std::iter::once("SELECT * FROM boom;".to_owned()) + .chain((1..2_000).map(|n| format!("SELECT {n};"))) + .collect(); + let harness = editor_with(runs, &script.join("\n")); + + run_and_wait(&harness); + + let results = &harness.editor.imp().results; + assert_eq!(results.n_entries(), 2_000); + assert_eq!( + results.imp().stack.pages().n_items(), + 2, + "pages were built for lines nobody opened" + ); +} + #[gtk4::test] fn an_open_transaction_marks_the_tab() { let mut runs = Replies::default(); From 9e0306171cc4d5c9d795cb1ebfa9bfd9c26e8614 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:29:26 +0700 Subject: [PATCH 023/178] fix(linux-core): export reals and array items at column precision A real went through f64 before printing, so 0.1 exported as 0.10000000149011612. Array items now take their element's precision, and an inner dimension stays an array. Copy as IN clause keeps the widened double, which is what engines compare a real against. --- linux/CHANGELOG.md | 2 + linux/crates/core/src/export/in_clause.rs | 20 ++- linux/crates/core/src/export/json.rs | 111 ++++++++++------ linux/crates/core/src/export/value_text.rs | 148 ++++++++++++++++----- 4 files changed, 203 insertions(+), 78 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 5aa8e0614c..485b4b0cd8 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -18,3 +18,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. +- `real` values showed extra digits in the grid, copies and exports, such as 0.10000000149011612 for 0.1. +- Exported arrays wrote each row of a multi-dimensional array as a quoted string. diff --git a/linux/crates/core/src/export/in_clause.rs b/linux/crates/core/src/export/in_clause.rs index ac5b1bd3a3..15ddaf3ac2 100644 --- a/linux/crates/core/src/export/in_clause.rs +++ b/linux/crates/core/src/export/in_clause.rs @@ -10,12 +10,11 @@ fn in_clause_literal(v: &Value) -> Option { // the caller rather than written. Value::Null | Value::Bytes(_) => None, Value::Bool(b) => Some(if *b { "TRUE".to_string() } else { "FALSE".to_string() }), - Value::Int(_) - | Value::UInt(_) - | Value::WideInt(_) - | Value::Float32(_) - | Value::Float64(_) - | Value::Decimal(_) => value_to_text(v), + // The widened double, not the real's own digits: an engine + // compares a `real` with a numeric literal as a double, and + // `0.1` as a double is not the `real` 0.1. + Value::Float32(real) => value_to_text(&Value::Float64(f64::from(*real))), + Value::Int(_) | Value::UInt(_) | Value::WideInt(_) | Value::Float64(_) | Value::Decimal(_) => value_to_text(v), other => value_to_text(other).map(|s| format!("'{}'", s.replace('\'', "''"))), } } @@ -62,6 +61,15 @@ mod tests { assert_eq!(out.skipped, 1); } + #[test] + fn in_clause_writes_a_real_as_the_double_it_compares_as() { + let rows = vec![vec![Value::Float32(0.1)]]; + + let out = render_in_clause(&rows, 0); + + assert_eq!(out.sql, format!("({})", f64::from(0.1_f32))); + } + #[test] fn in_clause_is_empty_rather_than_invalid_when_all_skipped() { let rows = vec![vec![Value::Null], vec![Value::Bytes(vec![1, 2])]]; diff --git a/linux/crates/core/src/export/json.rs b/linux/crates/core/src/export/json.rs index faed9fffe7..81585d0424 100644 --- a/linux/crates/core/src/export/json.rs +++ b/linux/crates/core/src/export/json.rs @@ -1,12 +1,12 @@ use serde::ser::{Serialize, SerializeMap, SerializeSeq, Serializer}; use serde_json::value::RawValue; -use crate::column::{ColumnType, ResultColumn}; +use crate::column::{ColumnKind, ResultColumn}; use crate::value::Value; use super::column_type; use super::json_field_names::json_field_names; -use super::value_text::{value_text, value_to_text}; +use super::value_text::{element_kind, kind_text}; /// One row as a JSON object, keyed by the names the columns resolve to. struct JsonRow<'a> { @@ -24,7 +24,7 @@ impl Serialize for JsonRow<'_> { name, &JsonCell { value, - column: column_type(self.columns, index), + kind: &column_type(self.columns, index).kind, }, )?, // A row shorter than its header: the cell is missing, @@ -43,7 +43,7 @@ impl Serialize for JsonRow<'_> { /// value on the way through one. struct JsonCell<'a> { value: &'a Value, - column: &'a ColumnType, + kind: &'a ColumnKind, } impl Serialize for JsonCell<'_> { @@ -54,10 +54,12 @@ impl Serialize for JsonCell<'_> { Value::Int(number) => serializer.serialize_i64(*number), Value::UInt(number) => serializer.serialize_u64(*number), Value::WideInt(number) => number_token(serializer, &number.to_string()), - Value::Float32(number) => finite_number(serializer, f64::from(*number)), - Value::Float64(number) => finite_number(serializer, *number), + // Each width at its own shortest digits: a `real` widened + // to a double prints `0.10000000149011612` for 0.1. + Value::Float32(number) if number.is_finite() => serializer.serialize_f32(*number), + Value::Float64(number) if number.is_finite() => serializer.serialize_f64(*number), Value::Decimal(decimal) if decimal.is_finite() => { - number_token(serializer, &value_text(self.value, self.column).unwrap_or_default()) + number_token(serializer, &kind_text(self.value, self.kind).unwrap_or_default()) } // A JSON column already holds a document, so it goes in as // one rather than as a string of one. @@ -66,20 +68,21 @@ impl Serialize for JsonCell<'_> { Err(_) => serializer.serialize_str(json.as_str()), }, Value::Array(values) => { + let element = element_kind(self.kind); let mut seq = serializer.serialize_seq(Some(values.len()))?; for value in values { - seq.serialize_element(&JsonCell { - value, - column: self.column, - })?; + seq.serialize_element(&JsonCell { value, kind: element })?; } seq.end() } Value::Timestamp(_) | Value::TimestampTz(_) => { - let text = value_text(self.value, self.column).unwrap_or_default(); + let text = kind_text(self.value, self.kind).unwrap_or_default(); serializer.serialize_str(&text.replacen(' ', "T", 1)) } - other => match value_text(other, self.column).or_else(|| value_to_text(other)) { + // JSON has no NaN or Infinity, so a non-finite number goes + // out as its name rather than as a null that reads like a + // missing value. + other => match kind_text(other, self.kind) { Some(text) => serializer.serialize_str(&text), None => serializer.serialize_none(), }, @@ -98,22 +101,6 @@ fn number_token(serializer: S, digits: &str) -> Result(serializer: S, number: f64) -> Result { - if number.is_finite() { - return serializer.serialize_f64(number); - } - let text = if number.is_nan() { - "NaN" - } else if number.is_sign_negative() { - "-Infinity" - } else { - "Infinity" - }; - serializer.serialize_str(text) -} - /// One row as a document a person reads, which is the only place a /// single row is rendered on its own. pub fn row_to_json(columns: &[ResultColumn], row: &[Value]) -> String { @@ -169,7 +156,7 @@ mod tests { } #[test] - fn a_number_wider_than_a_double_keeps_its_digits() { + fn json_emits_raw_number_tokens_for_u64_max_and_decimal_40_20() { let columns = vec![ typed( "bigint unsigned", @@ -224,25 +211,73 @@ mod tests { } #[test] - fn a_json_column_embeds_its_document_rather_than_a_string_of_one() { + fn json_embeds_json_column_verbatim() { + let document = r#"{"b": 1, "a": [1, 2]}"#; let columns = vec![typed("payload", "jsonb", ColumnKind::Json { binary: true })]; let row = vec![Value::Json( - crate::value::JsonText::parse(r#"{"a": [1, 2]}"#.to_owned()).expect("valid json"), + crate::value::JsonText::parse(document.to_owned()).expect("valid json"), )]; + let text = row_to_json(&columns, &row); + + assert!( + text.contains(&format!("\"payload\": {document}")), + "the document was rewritten or quoted: {text}" + ); + } + + #[test] + fn json_non_finite_is_string() { + let columns = cols(&["double", "real", "nan", "negative"]); + let row = vec![ + Value::Float64(f64::INFINITY), + Value::Float32(f32::NAN), + Value::Decimal("NaN".parse().expect("a decimal")), + Value::Decimal("-Infinity".parse().expect("a decimal")), + ]; + let json = parsed(&columns, &row); - assert_eq!(json["payload"], serde_json::json!({"a": [1, 2]})); + assert_eq!(json["double"], serde_json::json!("Infinity")); + assert_eq!(json["real"], serde_json::json!("NaN")); + assert_eq!(json["nan"], serde_json::json!("NaN")); + assert_eq!(json["negative"], serde_json::json!("-Infinity")); + } + + #[test] + fn a_real_goes_out_at_its_own_digits() { + let columns = cols(&["r"]); + let row = vec![Value::Float32(0.1)]; + + let text = row_to_json(&columns, &row); + + assert!( + text.contains("\"r\": 0.1\n"), + "the real was widened to a double: {text}" + ); } #[test] - fn json_non_finite_float_is_string() { - let columns = cols(&["v"]); - let rows = vec![vec![Value::Float64(f64::INFINITY)]]; + fn array_items_go_out_at_their_element_scale() { + let columns = vec![typed( + "amounts", + "numeric(5,2)[]", + ColumnKind::Array { + element: Box::new(ColumnKind::Decimal { + precision: Some(5), + scale: Some(2), + }), + }, + )]; + let row = vec![Value::Array(vec![ + Value::Decimal("1.5".parse().expect("a decimal")), + Value::Null, + ])]; - let out = render_json(&columns, &rows); + let text = row_to_json(&columns, &row); - assert!(out.contains("\"Infinity\""), "{out}"); + assert!(text.contains("1.50,"), "the item lost its column's scale: {text}"); + assert_eq!(parsed(&columns, &row)["amounts"], serde_json::json!([1.5, null])); } #[test] diff --git a/linux/crates/core/src/export/value_text.rs b/linux/crates/core/src/export/value_text.rs index 32b0478e23..d9684088e1 100644 --- a/linux/crates/core/src/export/value_text.rs +++ b/linux/crates/core/src/export/value_text.rs @@ -1,6 +1,6 @@ use chrono::Timelike; -use crate::column::ColumnType; +use crate::column::{ColumnKind, ColumnType}; use crate::value::{SqlDecimal, Value}; /// Full text of a value for export, at the precision the column @@ -12,18 +12,22 @@ use crate::value::{SqlDecimal, Value}; /// `numeric(38,10)` holding `1`. Export writes what the column says, /// because that is what the server would print. pub fn value_text(value: &Value, column: &ColumnType) -> Option { + kind_text(value, &column.kind) +} + +pub(super) fn kind_text(value: &Value, kind: &ColumnKind) -> Option { match value { - Value::Time(time) => Some(time.format(second_digits(column))), + Value::Time(time) => Some(time.format(second_digits(kind))), Value::TimeTz(time) => Some(format!( "{}{}", - time.time.format(second_digits(column)), + time.time.format(second_digits(kind)), offset_text(time.offset) )), Value::Timestamp(stamp) => temporal_text(stamp, |stamp| { format!( "{}{}", stamp.format("%Y-%m-%d %H:%M:%S"), - fraction_text(stamp.nanosecond(), column.kind.fractional_digits()) + fraction_text(stamp.nanosecond(), kind.fractional_digits()) ) }), Value::TimestampTz(stamp) => temporal_text(stamp, |stamp| { @@ -31,18 +35,29 @@ pub fn value_text(value: &Value, column: &ColumnType) -> Option { format!( "{}{}{}", stamp.format("%Y-%m-%d %H:%M:%S"), - fraction_text(stamp.nanosecond(), column.kind.fractional_digits()), + fraction_text(stamp.nanosecond(), kind.fractional_digits()), stamp.format("%:z") ) }), - Value::Decimal(decimal) => Some(decimal_text(decimal, column.kind.decimal_scale())), + Value::Decimal(decimal) => Some(decimal_text(decimal, kind.decimal_scale())), + Value::Array(values) => Some(array_text(values, Some(element_kind(kind)))), other => value_to_text(other), } } +/// The kind each item of an array holds. A PostgreSQL `int4[][]` is +/// declared once for every dimension, so a kind that is not itself an +/// array passes down unchanged. +pub(super) fn element_kind(kind: &ColumnKind) -> &ColumnKind { + match kind { + ColumnKind::Array { element } => element, + other => other, + } +} + /// The digits a time type declares, in the width `SqlTime` formats at. -fn second_digits(column: &ColumnType) -> Option { - column.kind.fractional_digits().map(|digits| digits.min(9)) +fn second_digits(kind: &ColumnKind) -> Option { + kind.fractional_digits().map(|digits| digits.min(9)) } /// The fractional second, at the digits the column declares. @@ -99,15 +114,13 @@ pub fn value_to_text(v: &Value) -> Option { Value::Int(i) => Some(i.to_string()), Value::UInt(i) => Some(i.to_string()), Value::WideInt(i) => Some(i.to_string()), - // Shortest round-trip form, so the text parses back to the - // same bits. The non-finite ones have no numeric spelling. - Value::Float32(f) => Some(float_text( - f64::from(*f), - f.is_nan(), - f.is_infinite(), - f.is_sign_negative(), - )), - Value::Float64(f) => Some(float_text(*f, f.is_nan(), f.is_infinite(), f.is_sign_negative())), + // Shortest round-trip form at the value's own width: an f32 + // widened first prints `0.10000000149011612` for a `real` + // holding 0.1, digits the column never held. + Value::Float32(f) if f.is_finite() => Some(f.to_string()), + Value::Float64(f) if f.is_finite() => Some(f.to_string()), + Value::Float32(f) => Some(non_finite_text(f.is_nan(), f.is_sign_negative())), + Value::Float64(f) => Some(non_finite_text(f.is_nan(), f.is_sign_negative())), // Full scale: trimming a trailing zero loses the precision // the column declared. Value::Decimal(d) => Some(d.to_string()), @@ -127,7 +140,7 @@ pub fn value_to_text(v: &Value) -> Option { }), Value::Interval(i) => Some(i.to_string()), Value::Bits(b) => Some(b.to_string()), - Value::Array(values) => Some(array_text(values)), + Value::Array(values) => Some(array_text(values, None)), Value::Other(other) => Some(other.text.clone()), // The driver could not read it, so there is no text to write. // The caller reports the cell rather than inventing one. @@ -135,14 +148,13 @@ pub fn value_to_text(v: &Value) -> Option { } } -fn float_text(value: f64, is_nan: bool, is_infinite: bool, is_negative: bool) -> String { - if is_nan { - return "NaN".to_owned(); - } - if is_infinite { - return if is_negative { "-Infinity" } else { "Infinity" }.to_owned(); +fn non_finite_text(is_nan: bool, is_negative: bool) -> String { + match (is_nan, is_negative) { + (true, _) => "NaN", + (false, true) => "-Infinity", + (false, false) => "Infinity", } - value.to_string() + .to_owned() } /// An infinite date or timestamp has a keyword rather than a number. @@ -162,13 +174,18 @@ fn offset_text(offset: chrono::FixedOffset) -> String { } /// An array as JSON array text, which is the one spelling every engine -/// reads back. -fn array_text(values: &[Value]) -> String { +/// reads back. Each item is written at the precision its element kind +/// declares, when there is one, and an inner dimension stays an array +/// rather than becoming the string of one. +fn array_text(values: &[Value], element: Option<&ColumnKind>) -> String { let parts: Vec = values .iter() - .map(|value| match value_to_text(value) { - Some(text) => serde_json::Value::String(text).to_string(), - None => "null".to_owned(), + .map(|value| match value { + Value::Array(inner) => array_text(inner, element.map(element_kind)), + other => match element.map_or_else(|| value_to_text(other), |kind| kind_text(other, kind)) { + Some(text) => serde_json::Value::String(text).to_string(), + None => "null".to_owned(), + }, }) .collect(); format!("[{}]", parts.join(",")) @@ -314,7 +331,7 @@ mod tests { } #[test] - fn a_timestamp_keeps_the_microseconds_its_column_declares() { + fn value_text_timestamp_keeps_declared_microseconds() { let column = typed("timestamp(6)", stamp(Some(6), TemporalZone::Naive)); let stamp = Value::Timestamp(Temporal::Finite( NaiveDate::from_ymd_opt(2024, 6, 15) @@ -330,7 +347,7 @@ mod tests { } #[test] - fn a_time_takes_its_precision_from_its_column() { + fn value_text_time_precision_from_kind() { let value = Value::Time(SqlTime::new(false, 12, 0, 0, 123_456_000).expect("a time")); assert_eq!( @@ -349,7 +366,7 @@ mod tests { } #[test] - fn a_timestamp_with_a_zone_keeps_its_offset() { + fn value_text_timestamptz_keeps_offset() { let column = typed("timestamptz(3)", stamp(Some(3), TemporalZone::Offset)); let stamp = chrono::DateTime::parse_from_rfc3339("2024-06-15T12:00:00.5+05:30").expect("a timestamp"); let value = Value::TimestampTz(Temporal::Finite(crate::value::OffsetTimestamp::from_datetime(stamp))); @@ -361,7 +378,7 @@ mod tests { } #[test] - fn a_decimal_fills_out_the_scale_its_column_declares() { + fn value_text_numeric_38_10_full_scale() { let column = typed("numeric(38,10)", decimal(38, 10)); assert_eq!( @@ -385,4 +402,67 @@ mod tests { "the server's own digits were rounded away" ); } + + #[test] + fn a_real_prints_its_own_shortest_digits() { + for real in [0.1_f32, 1.1, -3.3, 16_777_216.0] { + let text = value_to_text(&Value::Float32(real)).expect("a real has text"); + + assert_eq!(text, real.to_string(), "{real} was widened before printing"); + assert_eq!(text.parse::().ok(), Some(real), "{text} does not read back"); + } + assert_eq!( + value_to_text(&Value::Float32(f32::NEG_INFINITY)).as_deref(), + Some("-Infinity") + ); + } + + #[test] + fn array_items_take_the_precision_of_their_element() { + let column = typed( + "timestamp(6)[]", + ColumnKind::Array { + element: Box::new(stamp(Some(6), TemporalZone::Naive)), + }, + ); + let value = Value::Array(vec![ + Value::Timestamp(Temporal::Finite( + NaiveDate::from_ymd_opt(2024, 6, 15) + .expect("a date") + .and_hms_nano_opt(12, 0, 0, 100_000_000) + .expect("a time"), + )), + Value::Null, + ]); + + assert_eq!( + value_text(&value, &column).as_deref(), + Some(r#"["2024-06-15 12:00:00.100000",null]"#) + ); + } + + #[test] + fn an_inner_dimension_stays_an_array() { + let column = typed( + "numeric(5,2)[]", + ColumnKind::Array { + element: Box::new(decimal(5, 2)), + }, + ); + let value = Value::Array(vec![ + Value::Array(vec![Value::Decimal("1".parse().expect("a decimal"))]), + Value::Array(vec![Value::Decimal("2.5".parse().expect("a decimal"))]), + ]); + + assert_eq!( + value_text(&value, &column).as_deref(), + Some(r#"[["1.00"],["2.50"]]"#), + "a row of a two-dimensional array was written as a string" + ); + assert_eq!( + value_to_text(&value).as_deref(), + Some(r#"[["1"],["2.5"]]"#), + "without a column, a row of the array was written as a string" + ); + } } From 12ffe8fc4e02aa322e002d9e5fb2ed2bdcb28f7e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:29:31 +0700 Subject: [PATCH 024/178] fix(linux-core): neutralise formulas in CSV values of unplaced types A type the driver could not place carries the server's own text, so a citext value starting with = reached the sheet as a formula. D17 lists Other beside Text and Json. --- linux/CHANGELOG.md | 4 ++++ linux/crates/core/src/export/csv.rs | 36 +++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 485b4b0cd8..c1249835fe 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -20,3 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. - `real` values showed extra digits in the grid, copies and exports, such as 0.10000000149011612 for 0.1. - Exported arrays wrote each row of a multi-dimensional array as a quoted string. + +### Security + +- CSV export did not neutralise formulas in values of a type TablePro does not recognise. diff --git a/linux/crates/core/src/export/csv.rs b/linux/crates/core/src/export/csv.rs index 213c725181..44e72c4ea8 100644 --- a/linux/crates/core/src/export/csv.rs +++ b/linux/crates/core/src/export/csv.rs @@ -36,9 +36,10 @@ pub fn csv_writer_builder(opts: &CsvOptions) -> csv::WriterBuilder { /// Only free text can smuggle a formula into a spreadsheet. A typed /// number, date or UUID, and text that is itself a plain number, is data /// the spreadsheet already reads correctly, and prefixing it would turn -/// `-5` into the string `'-5`. +/// `-5` into the string `'-5`. A type the driver could not place is the +/// server's own text, which is as free as any. pub fn neutralise_formula(value: &Value, text: String, is_header: bool) -> String { - if is_header || matches!(value, Value::Text(_) | Value::Json(_)) { + if is_header || matches!(value, Value::Text(_) | Value::Json(_) | Value::Other(_)) { neutralise_text(text) } else { text @@ -395,6 +396,37 @@ mod tests { } } + #[test] + fn csv_formula_rule_unchanged_after_split() { + let columns = cols(&["a"]); + let other = |text: &str| { + Value::Other(Box::new(crate::value::OtherValue { + type_name: "citext".to_owned(), + text: text.to_owned(), + })) + }; + let neutralised = [ + Value::Text("=cmd".into()), + Value::Json(crate::value::JsonText::parse("\t{}".to_owned()).expect("valid json")), + other("-2+cmd"), + ]; + for value in neutralised { + let out = render_csv(&columns, &[vec![value.clone()]], &CsvOptions::default()).unwrap(); + assert!(out.contains("\"'"), "{value:?} reached the sheet as a formula: {out}"); + } + let kept = [ + (Value::Int(-5), "-5"), + (Value::Float64(-1.5), "-1.5"), + (Value::Decimal("-12.30".parse().expect("a decimal")), "-12.30"), + (Value::Text("-12".into()), "-12"), + (other("+1.5e3"), "+1.5e3"), + ]; + for (value, text) in kept { + let out = render_csv(&columns, &[vec![value]], &CsvOptions::default()).unwrap(); + assert_eq!(out, format!("\"a\"\n\"{text}\"\n"), "{text} is data, not a formula"); + } + } + #[test] fn csv_neutralises_formula_header() { let columns = cols(&["=HYPERLINK(\"x\")"]); From 7b6b989568a87a4e1a86fd249a37ef55ce533f23 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:29:41 +0700 Subject: [PATCH 025/178] fix(linux-core): write integers and scaled decimals to Excel exactly Int and UInt went in as f64 unchecked, so a bigint past 2^53 changed. A numeric(10,2) holding 1.50 compared against the f64's 1.5 and went in as text. A real now goes in as the double its own digits name. --- linux/CHANGELOG.md | 1 + linux/crates/core/src/export/xlsx.rs | 89 +++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index c1249835fe..cf4edb43b8 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. - `real` values showed extra digits in the grid, copies and exports, such as 0.10000000149011612 for 0.1. +- Excel export rounded integers past 2^53 and wrote decimals as text when their column padded the scale. - Exported arrays wrote each row of a multi-dimensional array as a quoted string. ### Security diff --git a/linux/crates/core/src/export/xlsx.rs b/linux/crates/core/src/export/xlsx.rs index cb102b5233..8dac182854 100644 --- a/linux/crates/core/src/export/xlsx.rs +++ b/linux/crates/core/src/export/xlsx.rs @@ -72,10 +72,6 @@ fn write_cell( // read the two differently, and NULL is the absent one. Value::Null => Ok(()), Value::Bool(flag) => sheet.write_boolean(row, column, *flag).map(drop), - Value::Int(number) => sheet.write_number(row, column, *number as f64).map(drop), - Value::UInt(number) => sheet.write_number(row, column, *number as f64).map(drop), - Value::Float32(number) => sheet.write_number(row, column, f64::from(*number)).map(drop), - Value::Float64(number) => sheet.write_number(row, column, *number).map(drop), Value::Date(Temporal::Finite(day)) => sheet.write_datetime_with_format(row, column, day, date).map(drop), Value::Timestamp(Temporal::Finite(at)) => { sheet.write_datetime_with_format(row, column, at, timestamp).map(drop) @@ -85,20 +81,30 @@ fn write_cell( Value::TimestampTz(Temporal::Finite(at)) => sheet .write_datetime_with_format(row, column, at.utc(), timestamp) .map(drop), - // A decimal is written as a number only when Excel can hold it: - // a sheet stores every number as an f64, so one that does not - // round-trip would be silently changed. The rest keep their - // digits as text, which is what the user asked the database - // for. - Value::Decimal(_) | Value::WideInt(_) => match exact_number(columns, column, value) { + _ => match sheet_number(columns, column, value) { Some(number) => sheet.write_number(row, column, number).map(drop), None => write_text(sheet, row, column, columns, value), }, - _ => write_text(sheet, row, column, columns, value), } .map_err(xlsx_error) } +/// The number a cell goes in as, or `None` for one that goes in as +/// text. +/// +/// An integer or decimal is a number only when Excel can hold it: a +/// sheet stores every number as an f64, so a `bigint` past 2^53 or a +/// long decimal would be silently changed. The rest keep their digits +/// as text, which is what the user asked the database for. +fn sheet_number(columns: &[ResultColumn], column: u16, value: &Value) -> Option { + match value { + Value::Float32(number) => Some(real_number(*number)), + Value::Float64(number) => Some(*number), + Value::Int(_) | Value::UInt(_) | Value::Decimal(_) | Value::WideInt(_) => exact_number(columns, column, value), + _ => None, + } +} + /// Everything else goes in as the same text the other exports write, /// so a cell reads the same whichever format the user picked. fn write_text( @@ -114,19 +120,33 @@ fn write_text( } } -/// The value as an f64, when that f64 prints back as the same digits. +/// The value as an f64, when that f64 prints back as the same number. /// `None` when it does not, which is the case this exists to catch. fn exact_number(columns: &[ResultColumn], column: u16, value: &Value) -> Option { let text = cell_text(columns, column as usize, value)?; let parsed: f64 = text.parse().ok()?; // `{}` on an f64 prints the shortest form that round-trips, so // comparing against it is comparing the value, not the spelling. - match format!("{parsed}") == text { + // The zeros a `numeric(10,2)` pads `1.50` with are spelling. + match without_scale_padding(&format!("{parsed}")) == without_scale_padding(&text) { true => Some(parsed), false => None, } } +fn without_scale_padding(text: &str) -> &str { + match text.contains('.') { + true => text.trim_end_matches('0').trim_end_matches('.'), + false => text, + } +} + +/// The double a `real`'s own shortest digits name. Widening its bits +/// instead hands the sheet `0.100000001490116` for a `real` holding 0.1. +fn real_number(number: f32) -> f64 { + number.to_string().parse().unwrap_or(f64::from(number)) +} + /// A sheet name Excel accepts: at most 31 characters, none of /// `[]:*?/\`, not empty, and not wrapped in apostrophes. fn sheet_title(name: &str) -> String { @@ -152,7 +172,8 @@ fn xlsx_error(error: rust_xlsxwriter::XlsxError) -> EncodeError { #[cfg(test)] mod tests { use super::*; - use crate::export::test_columns::{col, cols}; + use crate::column::ColumnKind; + use crate::export::test_columns::{col, cols, typed}; fn workbook_bytes(columns: &[ResultColumn], rows: &[Vec]) -> Vec { render_xlsx(columns, rows, "Results").expect("render") @@ -209,6 +230,46 @@ mod tests { assert_eq!(exact_number(&columns, 0, &value), None); } + #[test] + fn a_decimal_padded_to_its_scale_is_still_a_number() { + let columns = vec![typed( + "amount", + "numeric(10,2)", + ColumnKind::Decimal { + precision: Some(10), + scale: Some(2), + }, + )]; + + for (digits, number) in [("1.5", 1.5), ("2", 2.0), ("-0.10", -0.1), ("100", 100.0)] { + let value = Value::Decimal(digits.parse().expect("decimal")); + assert_eq!(sheet_number(&columns, 0, &value), Some(number), "{digits}"); + } + } + + #[test] + fn an_integer_past_an_f64_stays_text() { + let columns = cols(&["id"]); + + assert_eq!(sheet_number(&columns, 0, &Value::Int(42)), Some(42.0)); + assert_eq!( + sheet_number(&columns, 0, &Value::Int(-(1 << 53))), + Some(-9_007_199_254_740_992.0) + ); + assert_eq!(sheet_number(&columns, 0, &Value::Int((1 << 53) + 1)), None); + assert_eq!(sheet_number(&columns, 0, &Value::UInt(u64::MAX)), None); + } + + #[test] + fn a_real_is_written_as_the_number_it_prints_as() { + let columns = cols(&["r"]); + + assert_eq!(sheet_number(&columns, 0, &Value::Float32(0.1)), Some(0.1)); + assert_eq!(sheet_number(&columns, 0, &Value::Float32(-3.3)), Some(-3.3)); + assert!(sheet_number(&columns, 0, &Value::Float32(f32::NAN)).is_some_and(f64::is_nan)); + assert_eq!(sheet_number(&columns, 0, &Value::Text("1".to_owned())), None); + } + #[test] fn a_result_wider_than_a_sheet_is_refused() { let columns: Vec = (0..MAX_COLUMNS + 1).map(|index| col(&index.to_string())).collect(); From 712bc27ff875c96537145c1143082d32f7562859 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:29:45 +0700 Subject: [PATCH 026/178] fix(linux-core): escape pipes in Markdown export column names --- linux/CHANGELOG.md | 1 + linux/crates/core/src/export/markdown.rs | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index cf4edb43b8..94bc50f530 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `real` values showed extra digits in the grid, copies and exports, such as 0.10000000149011612 for 0.1. - Excel export rounded integers past 2^53 and wrote decimals as text when their column padded the scale. - Exported arrays wrote each row of a multi-dimensional array as a quoted string. +- Markdown export broke its table when a column name held a pipe. ### Security diff --git a/linux/crates/core/src/export/markdown.rs b/linux/crates/core/src/export/markdown.rs index 73ce932309..b97c7f0cf1 100644 --- a/linux/crates/core/src/export/markdown.rs +++ b/linux/crates/core/src/export/markdown.rs @@ -5,7 +5,12 @@ use super::column_type; use super::value_text::value_text; fn markdown_cell(value: &Value, column: &ColumnType) -> String { - let text = value_text(value, column).unwrap_or_else(|| "NULL".to_string()); + markdown_text(&value_text(value, column).unwrap_or_else(|| "NULL".to_string())) +} + +/// A pipe or a line break, in a cell or a column name alike, would end +/// the cell or the row early. +fn markdown_text(text: &str) -> String { text.replace('|', "\\|") .replace("\r\n", "
") .replace(['\r', '\n'], "
") @@ -13,7 +18,7 @@ fn markdown_cell(value: &Value, column: &ColumnType) -> String { pub fn render_markdown(columns: &[ResultColumn], rows: &[Vec]) -> String { let mut lines: Vec = Vec::new(); - let header: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect(); + let header: Vec = columns.iter().map(|c| markdown_text(&c.name)).collect(); lines.push(format!("| {} |", header.join(" | "))); let separator: Vec<&str> = columns.iter().map(|_| "---").collect(); lines.push(format!("| {} |", separator.join(" | "))); @@ -42,4 +47,14 @@ mod tests { assert_eq!(out, "| a |\n| --- |\n| has\\|pipe
and newline |"); } + + #[test] + fn markdown_escapes_a_pipe_in_a_column_name() { + let columns = cols(&["a|b", "c"]); + let rows = vec![vec![Value::Int(1), Value::Int(2)]]; + + let out = render_markdown(&columns, &rows); + + assert_eq!(out, "| a\\|b | c |\n| --- | --- |\n| 1 | 2 |"); + } } From 9a1aca60f431e5f578cec5e8fe5190e8bbbfb661 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:31:19 +0700 Subject: [PATCH 027/178] fix(linux-core): refuse a MySQL # comment in a raw filter or type --- linux/crates/core/src/dialect/test_dialect.rs | 12 ++++-- .../core/src/filter/validate_raw_predicate.rs | 11 +++++ .../core/src/sql_syntax/parse_expression.rs | 14 +++++- .../crates/core/src/sql_syntax/parse_type.rs | 43 +++++++++---------- 4 files changed, 52 insertions(+), 28 deletions(-) diff --git a/linux/crates/core/src/dialect/test_dialect.rs b/linux/crates/core/src/dialect/test_dialect.rs index a754cfb272..054b93f6fd 100644 --- a/linux/crates/core/src/dialect/test_dialect.rs +++ b/linux/crates/core/src/dialect/test_dialect.rs @@ -22,6 +22,7 @@ pub struct TestDialect { row_ids: bool, default_row: bool, count_all: &'static str, + grammar: SqlGrammar, binds: Mutex>, ddl: Option, } @@ -72,6 +73,7 @@ impl Default for TestDialect { row_ids: true, default_row: true, count_all: "COUNT(*)", + grammar: SqlGrammar::PostgreSql, binds: Mutex::new(Vec::new()), ddl: Some(TestDdl::default()), } @@ -114,6 +116,10 @@ impl TestDialect { Self { count_all, ..self } } + pub fn parsing_as(self, grammar: SqlGrammar) -> Self { + Self { grammar, ..self } + } + pub fn targets(&self) -> Vec { self.recorded().into_iter().map(|bind| bind.target).collect() } @@ -149,7 +155,7 @@ impl SqlDialect for TestDialect { } fn grammar(&self) -> SqlGrammar { - SqlGrammar::PostgreSql + self.grammar } fn quote_identifier(&self, name: &str) -> String { @@ -257,12 +263,12 @@ impl SqlDialect for TestDialect { } fn parse_type(&self, text: &str) -> Result { - let parsed = parse_type(SqlGrammar::PostgreSql, text, crate::sql_syntax::MAX_TYPE_LEN)?; + let parsed = parse_type(self.grammar, text, crate::sql_syntax::MAX_TYPE_LEN)?; Ok(ColumnType::untyped_text(parsed.text)) } fn parse_expression(&self, text: &str) -> Result { - let parsed = parse_expression(SqlGrammar::PostgreSql, text, crate::sql_syntax::MAX_EXPRESSION_LEN)?; + let parsed = parse_expression(self.grammar, text, crate::sql_syntax::MAX_EXPRESSION_LEN)?; Ok(SqlExpression::from_catalog_text(parsed.text)) } diff --git a/linux/crates/core/src/filter/validate_raw_predicate.rs b/linux/crates/core/src/filter/validate_raw_predicate.rs index 2cb8db3945..9b929698d0 100644 --- a/linux/crates/core/src/filter/validate_raw_predicate.rs +++ b/linux/crates/core/src/filter/validate_raw_predicate.rs @@ -17,6 +17,7 @@ pub fn validate_raw_predicate(dialect: &dyn SqlDialect, text: &str) -> Result<() #[cfg(test)] mod tests { use crate::dialect::TestDialect; + use crate::sql_syntax::SqlGrammar; use super::*; @@ -47,6 +48,16 @@ mod tests { assert_eq!(validate("a = 1 --"), Err(ExpressionSyntaxError::Comment)); } + #[test] + fn a_mysql_hash_comment_is_refused() { + let mysql = TestDialect::exact().parsing_as(SqlGrammar::MySql); + + assert_eq!( + validate_raw_predicate(&mysql, "a = 1 # x"), + Err(ExpressionSyntaxError::Comment) + ); + } + #[test] fn a_plain_expression_passes() { assert_eq!(validate("a OR b"), Ok(())); diff --git a/linux/crates/core/src/sql_syntax/parse_expression.rs b/linux/crates/core/src/sql_syntax/parse_expression.rs index e5eb9a9ca3..d5e292c5d3 100644 --- a/linux/crates/core/src/sql_syntax/parse_expression.rs +++ b/linux/crates/core/src/sql_syntax/parse_expression.rs @@ -5,7 +5,7 @@ use sqlparser::tokenizer::Token; use super::SqlGrammar; use super::errors::ExpressionSyntaxError; use super::literal_token::LiteralToken; -use super::parse_type::{RECURSION_LIMIT, reject_comment_tokens}; +use super::parse_type::{RECURSION_LIMIT, contains_comment}; use crate::value::SqlDecimal; /// An expression the engine's own parser accepted, plus the literal it @@ -33,7 +33,7 @@ pub fn parse_expression( if text.chars().count() > max_len { return Err(ExpressionSyntaxError::TooLong { max: max_len }); } - if reject_comment_tokens(grammar, text).is_err() && (text.contains("--") || text.contains("/*")) { + if contains_comment(grammar, text) { return Err(ExpressionSyntaxError::Comment); } @@ -152,4 +152,14 @@ mod tests { Err(ExpressionSyntaxError::TooLong { max: 2 }) ); } + + #[test] + fn a_hash_is_a_comment_only_where_the_engine_reads_one() { + assert_eq!( + parse_expression(SqlGrammar::MySql, "a = 1 # x", MAX_EXPRESSION_LEN), + Err(ExpressionSyntaxError::Comment) + ); + assert!(parse_expression(SqlGrammar::MySql, "a = '#'", MAX_EXPRESSION_LEN).is_ok()); + assert!(parse("a # b").is_ok(), "PostgreSQL reads # as XOR"); + } } diff --git a/linux/crates/core/src/sql_syntax/parse_type.rs b/linux/crates/core/src/sql_syntax/parse_type.rs index c55e9ed8c0..b08c2d4728 100644 --- a/linux/crates/core/src/sql_syntax/parse_type.rs +++ b/linux/crates/core/src/sql_syntax/parse_type.rs @@ -49,7 +49,9 @@ pub fn parse_type(grammar: SqlGrammar, text: &str, max_len: usize) -> Result max_len { return Err(TypeSyntaxError::TooLong { max: max_len }); } - reject_comments(grammar, text)?; + if contains_comment(grammar, text) { + return Err(TypeSyntaxError::Comment); + } let mut parser = Parser::new(grammar.parser_dialect()) .with_recursion_limit(RECURSION_LIMIT) @@ -71,28 +73,18 @@ pub fn parse_type(grammar: SqlGrammar, text: &str, max_len: usize) -> Result Result<(), ()> { - let tokens = Tokenizer::new(grammar.parser_dialect(), text) - .tokenize_with_location() - .map_err(|_| ())?; - let has_comment = tokens.iter().any(|token| { - matches!( - token.token, - Token::Whitespace(Whitespace::SingleLineComment { .. } | Whitespace::MultiLineComment(_)) - ) - }); - if has_comment { Err(()) } else { Ok(()) } -} - -fn reject_comments(grammar: SqlGrammar, text: &str) -> Result<(), TypeSyntaxError> { - match reject_comment_tokens(grammar, text) { - Ok(()) => Ok(()), - // A tokenizer failure is reported by the parser below with a - // better message, so only the comment case stops here. - Err(()) if text.contains("--") || text.contains("/*") => Err(TypeSyntaxError::Comment), - Err(()) => Ok(()), +/// A comment, MySQL's `#` included, swallows every clause after it. +/// Text the tokenizer refuses is left to the parser's message unless it +/// opens a comment, such as an unterminated `/*`. +pub(crate) fn contains_comment(grammar: SqlGrammar, text: &str) -> bool { + match Tokenizer::new(grammar.parser_dialect(), text).tokenize_with_location() { + Ok(tokens) => tokens.iter().any(|token| { + matches!( + token.token, + Token::Whitespace(Whitespace::SingleLineComment { .. } | Whitespace::MultiLineComment(_)) + ) + }), + Err(_) => text.contains("--") || text.contains("/*"), } } @@ -241,6 +233,11 @@ mod tests { } } + #[test] + fn parse_type_rejects_a_mysql_hash_comment() { + assert_eq!(parse(SqlGrammar::MySql, "int # x"), Err(TypeSyntaxError::Comment)); + } + #[test] fn parse_rejects_empty_and_over_length() { assert_eq!(parse(SqlGrammar::PostgreSql, " "), Err(TypeSyntaxError::Empty)); From 99aaf677a7c779e9e47854e3840717bb56a1a4bc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:31:35 +0700 Subject: [PATCH 028/178] test(linux-tests): bound every conformance case and each running count --- .../src/conformance/cases/cancellation.rs | 11 ++-- .../src/conformance/cases/close.rs | 3 +- .../src/conformance/cases/deadlines.rs | 10 +-- .../src/conformance/cases/liveness.rs | 5 +- .../src/conformance/cases/metadata.rs | 9 ++- .../test-support/src/conformance/cases/mod.rs | 4 +- .../src/conformance/cases/pool.rs | 13 ++-- .../src/conformance/cases/read_only.rs | 3 +- .../src/conformance/cases/row_guards.rs | 3 +- .../src/conformance/cases/row_limit.rs | 6 +- .../conformance/cases/statement_timeout.rs | 6 +- .../src/conformance/cases/streaming.rs | 10 +-- .../test-support/src/conformance/cases/tls.rs | 30 +++++---- .../src/conformance/cases/type_fallback.rs | 5 +- .../src/conformance/cases/writes.rs | 14 ++-- .../src/conformance/connection_suite_macro.rs | 39 +++++++++-- .../src/conformance/fake_engine.rs | 6 +- .../test-support/src/conformance/mod.rs | 2 + .../test-support/src/conformance/run_case.rs | 64 +++++++++++++++++++ .../test-support/src/conformance/support.rs | 30 ++++++++- .../src/conformance/tls_suite_macro.rs | 6 ++ linux/docs/testing.md | 2 + 22 files changed, 208 insertions(+), 73 deletions(-) create mode 100644 linux/crates/test-support/src/conformance/run_case.rs diff --git a/linux/crates/test-support/src/conformance/cases/cancellation.rs b/linux/crates/test-support/src/conformance/cases/cancellation.rs index 9f29d3130a..e95cb33a0e 100644 --- a/linux/crates/test-support/src/conformance/cases/cancellation.rs +++ b/linux/crates/test-support/src/conformance/cases/cancellation.rs @@ -20,9 +20,9 @@ const SEEN_LIMIT: Duration = Duration::from_secs(10); /// Case 3: cancelling an editor run ends it as cancelled within the /// cancel grace, and the session takes the next run. -pub async fn editor_cancel_reports_cancelled_and_session_stays_ready() --> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn editor_cancel_reports_cancelled_and_session_stays_ready( + engine: E, +) -> Result<(), ConformanceFailure> { let grace = test_liveness().cancel_grace + SCHEDULING_SLACK; let connection = connect(EDITOR_CASE, &engine, writable()).await?; let session = connection @@ -62,8 +62,9 @@ pub async fn editor_cancel_reports_cancelled_and_session_stays_ready() -> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn dropped_caller_stops_server_statement( + engine: E, +) -> Result<(), ConformanceFailure> { let bound = test_liveness().cancel_grace + Duration::from_secs(1); let connection = connect(DROPPED_CASE, &engine, writable()).await?; // An editor session owns its own connection, so the count never diff --git a/linux/crates/test-support/src/conformance/cases/close.rs b/linux/crates/test-support/src/conformance/cases/close.rs index 40d0bf09f6..903af70b79 100644 --- a/linux/crates/test-support/src/conformance/cases/close.rs +++ b/linux/crates/test-support/src/conformance/cases/close.rs @@ -7,8 +7,7 @@ const CASE: &str = "close_is_idempotent"; /// Case 14: closing twice is fine and quick, and a call after the close /// fails rather than reopening the pool or hanging. -pub async fn close_is_idempotent() -> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn close_is_idempotent(engine: E) -> Result<(), ConformanceFailure> { let liveness = test_liveness(); let connection = connect(CASE, &engine, writable()).await?; connection.probe(within(liveness.probe_timeout)).await?; diff --git a/linux/crates/test-support/src/conformance/cases/deadlines.rs b/linux/crates/test-support/src/conformance/cases/deadlines.rs index 719f09719f..155134308e 100644 --- a/linux/crates/test-support/src/conformance/cases/deadlines.rs +++ b/linux/crates/test-support/src/conformance/cases/deadlines.rs @@ -13,9 +13,9 @@ const DEADLINE: Duration = Duration::from_secs(1); /// Case 2: a read that never finishes returns a statement timeout within /// its deadline plus the cancel grace, and the connection still works. -pub async fn query_bound_times_out_within_deadline_plus_cancel_grace() --> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn query_bound_times_out_within_deadline_plus_cancel_grace( + engine: E, +) -> Result<(), ConformanceFailure> { let bound = DEADLINE + test_liveness().cancel_grace + SCHEDULING_SLACK; let connection = connect(CASE, &engine, writable()).await?; let unbounded = statement(&engine.unbounded_statement_sql(&unique("deadline"))); @@ -92,7 +92,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn deadline_case_fails_when_bound_exceeded() { - let outcome = query_bound_times_out_within_deadline_plus_cancel_grace::>().await; + let outcome = query_bound_times_out_within_deadline_plus_cancel_grace(FakeEngine::::new()).await; assert!( matches!(&outcome, Err(ConformanceFailure::Expectation { case: CASE, detail }) if detail.contains("past the")), @@ -102,7 +102,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn deadline_case_passes_when_the_driver_stops_at_the_deadline() { - query_bound_times_out_within_deadline_plus_cancel_grace::>() + query_bound_times_out_within_deadline_plus_cancel_grace(FakeEngine::::new()) .await .unwrap(); } diff --git a/linux/crates/test-support/src/conformance/cases/liveness.rs b/linux/crates/test-support/src/conformance/cases/liveness.rs index 1d9e373441..7f9e6f96bd 100644 --- a/linux/crates/test-support/src/conformance/cases/liveness.rs +++ b/linux/crates/test-support/src/conformance/cases/liveness.rs @@ -12,8 +12,9 @@ const DEADLINE: Duration = Duration::from_secs(1); /// Case 12: once the route goes quiet, a read reports the connection /// lost within its deadline plus the cancel grace, and the link says so /// to everything watching it. -pub async fn lost_transport_reports_connection_lost() -> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn lost_transport_reports_connection_lost( + engine: E, +) -> Result<(), ConformanceFailure> { let liveness = test_liveness(); let (options, fault) = engine.faulty_connect_options(writable(), liveness).await?; let connection = dial(CASE, E::driver(), options).await??; diff --git a/linux/crates/test-support/src/conformance/cases/metadata.rs b/linux/crates/test-support/src/conformance/cases/metadata.rs index 99f647552b..41d6d03440 100644 --- a/linux/crates/test-support/src/conformance/cases/metadata.rs +++ b/linux/crates/test-support/src/conformance/cases/metadata.rs @@ -7,9 +7,9 @@ const CASE: &str = "lists_created_table_with_primary_key_and_unique_identity"; /// Case 1: a table the suite creates shows up in the default schema, its /// key column is marked, and its rows can be named again by that key. -pub async fn lists_created_table_with_primary_key_and_unique_identity() --> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn lists_created_table_with_primary_key_and_unique_identity( + engine: E, +) -> Result<(), ConformanceFailure> { let connection = connect(CASE, &engine, writable()).await?; let table = create_scratch_table(&engine, connection.as_ref(), "metadata").await?; @@ -50,8 +50,7 @@ const SHARED_CASE: &str = "calls_share_one_call_options"; /// One operation hands the same options to each of its calls, as a page /// does for its columns, its row identity and its rows: a call that /// finishes leaves them usable for the next. -pub async fn calls_share_one_call_options() -> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn calls_share_one_call_options(engine: E) -> Result<(), ConformanceFailure> { let connection = connect(SHARED_CASE, &engine, writable()).await?; let table = create_scratch_table(&engine, connection.as_ref(), "shared").await?; let options = within(CALL_DEADLINE); diff --git a/linux/crates/test-support/src/conformance/cases/mod.rs b/linux/crates/test-support/src/conformance/cases/mod.rs index c8cbc8671b..e44f149368 100644 --- a/linux/crates/test-support/src/conformance/cases/mod.rs +++ b/linux/crates/test-support/src/conformance/cases/mod.rs @@ -1,5 +1,5 @@ -//! One file per area of the contract. Every case starts its own engine -//! and returns a `ConformanceFailure` rather than panicking. +//! One file per area of the contract. Every case gets an engine started +//! for it alone and returns a `ConformanceFailure` rather than panicking. pub mod cancellation; pub mod close; diff --git a/linux/crates/test-support/src/conformance/cases/pool.rs b/linux/crates/test-support/src/conformance/cases/pool.rs index 78447e50dd..e32b80cd77 100644 --- a/linux/crates/test-support/src/conformance/cases/pool.rs +++ b/linux/crates/test-support/src/conformance/cases/pool.rs @@ -18,19 +18,18 @@ const RETRY: Duration = Duration::from_millis(50); /// Case 5: with every pool slot running a statement, the probe still /// answers, because a busy pool is not a dead server. -pub async fn probe_succeeds_while_every_pool_slot_is_busy() -> Result<(), ConformanceFailure> -{ - let engine = E::start().await?; +pub async fn probe_succeeds_while_every_pool_slot_is_busy( + engine: E, +) -> Result<(), ConformanceFailure> { pool_case(&engine, None).await } /// Case 5 on a network engine, which also asks the server that exactly /// the pool's size of statements is running. -pub async fn probe_succeeds_while_every_network_pool_slot_is_busy() -> Result<(), ConformanceFailure> +pub async fn probe_succeeds_while_every_network_pool_slot_is_busy(engine: E) -> Result<(), ConformanceFailure> where E: PooledConformanceEngine + NetworkConformanceEngine, { - let engine = E::start().await?; let count_sql = |marker: &str| engine.running_statement_count_sql(marker); pool_case(&engine, Some(&count_sql)).await } @@ -171,7 +170,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn pool_case_fails_when_saturation_never_appears() { - let outcome = probe_succeeds_while_every_pool_slot_is_busy::>().await; + let outcome = probe_succeeds_while_every_pool_slot_is_busy(FakeEngine::::new()).await; assert!( matches!(&outcome, Err(ConformanceFailure::Expectation { case: CASE, detail }) if detail.contains("still ran")), @@ -181,7 +180,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn pool_case_passes_when_the_full_pool_reports_busy() { - probe_succeeds_while_every_pool_slot_is_busy::>() + probe_succeeds_while_every_pool_slot_is_busy(FakeEngine::::new()) .await .unwrap(); } diff --git a/linux/crates/test-support/src/conformance/cases/read_only.rs b/linux/crates/test-support/src/conformance/cases/read_only.rs index 2617442538..b0f1b38c10 100644 --- a/linux/crates/test-support/src/conformance/cases/read_only.rs +++ b/linux/crates/test-support/src/conformance/cases/read_only.rs @@ -10,8 +10,7 @@ const CASE: &str = "read_only_connection_refuses_writes"; /// Case 7: a read-only connection refuses a save, and its editor refuses /// both a row change and a schema change. -pub async fn read_only_connection_refuses_writes() -> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn read_only_connection_refuses_writes(engine: E) -> Result<(), ConformanceFailure> { let owner = connect(CASE, &engine, writable()).await?; let table = create_scratch_table(&engine, owner.as_ref(), "read_only").await?; let reader = connect(CASE, &engine, WorkspaceSettings::workspace(true)).await?; diff --git a/linux/crates/test-support/src/conformance/cases/row_guards.rs b/linux/crates/test-support/src/conformance/cases/row_guards.rs index 6500ded42a..a2a064ccbe 100644 --- a/linux/crates/test-support/src/conformance/cases/row_guards.rs +++ b/linux/crates/test-support/src/conformance/cases/row_guards.rs @@ -15,8 +15,7 @@ const MISSING_ID: i64 = 999; /// row, and fails on its guard without changing anything. The save is /// planned the way the grid plans one, so the guard is the one the app /// would send to this engine. -pub async fn row_guard_mismatch_applies_nothing() -> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn row_guard_mismatch_applies_nothing(engine: E) -> Result<(), ConformanceFailure> { let connection = connect(CASE, &engine, writable()).await?; let table = create_scratch_table(&engine, connection.as_ref(), "row_guard").await?; let dialect = connection.dialect(); diff --git a/linux/crates/test-support/src/conformance/cases/row_limit.rs b/linux/crates/test-support/src/conformance/cases/row_limit.rs index 52b8b0d38f..9ed501da95 100644 --- a/linux/crates/test-support/src/conformance/cases/row_limit.rs +++ b/linux/crates/test-support/src/conformance/cases/row_limit.rs @@ -11,9 +11,9 @@ const KEPT_ROWS: u64 = 100; /// Case 6: a run that stops at its row limit inside a transaction keeps /// the transaction, and what runs after it still commits. -pub async fn row_limit_keeps_transaction_and_later_statements() --> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn row_limit_keeps_transaction_and_later_statements( + engine: E, +) -> Result<(), ConformanceFailure> { let connection = connect(CASE, &engine, writable()).await?; let table = create_scratch_table(&engine, connection.as_ref(), "row_limit").await?; let dialect = connection.dialect(); diff --git a/linux/crates/test-support/src/conformance/cases/statement_timeout.rs b/linux/crates/test-support/src/conformance/cases/statement_timeout.rs index c528fcf479..fc78343651 100644 --- a/linux/crates/test-support/src/conformance/cases/statement_timeout.rs +++ b/linux/crates/test-support/src/conformance/cases/statement_timeout.rs @@ -12,9 +12,9 @@ const SCHEMA_CHANGE: Duration = Duration::from_secs(3); /// Case 11: the workspace statement timeout stops a slow row change, and /// the same work saved as a schema change runs to the end. -pub async fn schema_changes_batch_is_not_bounded_by_statement_timeout() --> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn schema_changes_batch_is_not_bounded_by_statement_timeout( + engine: E, +) -> Result<(), ConformanceFailure> { let settings = WorkspaceSettings { statement_timeout: STATEMENT_TIMEOUT, ..WorkspaceSettings::workspace(false) diff --git a/linux/crates/test-support/src/conformance/cases/streaming.rs b/linux/crates/test-support/src/conformance/cases/streaming.rs index fe4323c3cc..86dd0c17b8 100644 --- a/linux/crates/test-support/src/conformance/cases/streaming.rs +++ b/linux/crates/test-support/src/conformance/cases/streaming.rs @@ -30,9 +30,9 @@ const STREAM_DEADLINE: Duration = Duration::from_secs(120); /// sink in bounded batches, inside one started and finished result set, /// and a sink that stops reading ends the read without being reported /// as a whole one. The connection answers afterwards. -pub async fn stream_read_streams_every_row_in_bounded_batches() -> Result<(), ConformanceFailure> -{ - let engine = E::start().await?; +pub async fn stream_read_streams_every_row_in_bounded_batches( + engine: E, +) -> Result<(), ConformanceFailure> { let connection = connect(CASE, &engine, writable()).await?; let whole = Tally::default(); @@ -208,14 +208,14 @@ mod tests { #[tokio::test] async fn stream_case_passes_when_rows_arrive_in_batches() { - stream_read_streams_every_row_in_bounded_batches::>() + stream_read_streams_every_row_in_bounded_batches(FakeEngine::::new()) .await .unwrap(); } #[tokio::test] async fn stream_case_fails_when_rows_arrive_in_one_block() { - let outcome = stream_read_streams_every_row_in_bounded_batches::>().await; + let outcome = stream_read_streams_every_row_in_bounded_batches(FakeEngine::::new()).await; assert!( matches!(&outcome, Err(ConformanceFailure::Expectation { case: CASE, detail }) if detail.contains("one batch held")), diff --git a/linux/crates/test-support/src/conformance/cases/tls.rs b/linux/crates/test-support/src/conformance/cases/tls.rs index 11a07905f8..6f82e3b3ec 100644 --- a/linux/crates/test-support/src/conformance/cases/tls.rs +++ b/linux/crates/test-support/src/conformance/cases/tls.rs @@ -69,16 +69,17 @@ fn describe(outcome: &Result, DriverError>) -> String { } /// TLS case 1. -pub async fn verify_full_with_issuing_ca_connects() -> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn verify_full_with_issuing_ca_connects( + engine: E, +) -> Result<(), ConformanceFailure> { let tls = verified(&engine, engine.ca_file()); connects::(ISSUING_CASE, engine.connect_options(tls)).await } /// TLS case 2. -pub async fn verify_full_with_unrelated_ca_reports_unknown_issuer() --> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn verify_full_with_unrelated_ca_reports_unknown_issuer( + engine: E, +) -> Result<(), ConformanceFailure> { let tls = verified(&engine, engine.unrelated_ca_file()); let outcome = open::(UNRELATED_CASE, engine.connect_options(tls)).await?; expect( @@ -89,9 +90,9 @@ pub async fn verify_full_with_unrelated_ca_reports_unknown_issuer() --> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn verify_full_with_wrong_server_name_reports_name_mismatch( + engine: E, +) -> Result<(), ConformanceFailure> { let tls = verified(&engine, engine.ca_file()); let outcome = open::(NAME_CASE, engine.name_mismatch_connect_options(tls)).await?; expect( @@ -102,8 +103,7 @@ pub async fn verify_full_with_wrong_server_name_reports_name_mismatch() -> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn require_connects_without_ca(engine: E) -> Result<(), ConformanceFailure> { let tls = TlsConfig { mode: TlsMode::Require, ca_file: None, @@ -114,8 +114,9 @@ pub async fn require_connects_without_ca() -> Result<() } /// TLS case 5. -pub async fn disable_against_tls_only_server_is_refused() -> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn disable_against_tls_only_server_is_refused( + engine: E, +) -> Result<(), ConformanceFailure> { let expected = engine.plaintext_rejection_category(); let outcome = open::(DISABLE_CASE, engine.connect_options(TlsConfig::disabled())).await?; let refused = matches!(&outcome, Err(error) if error.category() == expected); @@ -126,8 +127,9 @@ pub async fn disable_against_tls_only_server_is_refused /// TLS case 6: the certificate principal gets in with its certificate /// and is refused without it. -pub async fn client_identity_is_required_by_the_server() -> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn client_identity_is_required_by_the_server( + engine: E, +) -> Result<(), ConformanceFailure> { let with_identity = TlsConfig { client_identity: Some(engine.client_identity()), ..verified(&engine, engine.ca_file()) diff --git a/linux/crates/test-support/src/conformance/cases/type_fallback.rs b/linux/crates/test-support/src/conformance/cases/type_fallback.rs index 44a3916362..af981881e0 100644 --- a/linux/crates/test-support/src/conformance/cases/type_fallback.rs +++ b/linux/crates/test-support/src/conformance/cases/type_fallback.rs @@ -9,8 +9,9 @@ const CASE: &str = "unsupported_types_never_decode_as_null"; /// Case 13: a value of a type the driver has no decoder for still reads /// back as something, never as a NULL that would hide it. -pub async fn unsupported_types_never_decode_as_null() -> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn unsupported_types_never_decode_as_null( + engine: E, +) -> Result<(), ConformanceFailure> { let cases = engine.unsupported_type_cases(); expect(CASE, !cases.is_empty(), || { "the engine declares unsupported types but lists none".to_owned() diff --git a/linux/crates/test-support/src/conformance/cases/writes.rs b/linux/crates/test-support/src/conformance/cases/writes.rs index 85b4ca39b0..20662a862e 100644 --- a/linux/crates/test-support/src/conformance/cases/writes.rs +++ b/linux/crates/test-support/src/conformance/cases/writes.rs @@ -9,8 +9,7 @@ const COMMITS_CASE: &str = "write_batch_commits_every_step"; const FAILED_CASE: &str = "failed_write_batch_outcome_matches_dml_atomicity"; /// Case 8: a save of three inserts reports each one and lands all three. -pub async fn write_batch_commits_every_step() -> Result<(), ConformanceFailure> { - let engine = E::start().await?; +pub async fn write_batch_commits_every_step(engine: E) -> Result<(), ConformanceFailure> { let connection = connect(COMMITS_CASE, &engine, writable()).await?; let table = create_scratch_table(&engine, connection.as_ref(), "writes").await?; let dialect = connection.dialect(); @@ -32,9 +31,9 @@ pub async fn write_batch_commits_every_step() -> Result<() /// Case 9: a save whose second statement fails leaves what the engine's /// atomicity promises: nothing on a transactional engine, the first row /// on one that applies statement by statement. -pub async fn failed_write_batch_outcome_matches_dml_atomicity() -> Result<(), ConformanceFailure> -{ - let engine = E::start().await?; +pub async fn failed_write_batch_outcome_matches_dml_atomicity( + engine: E, +) -> Result<(), ConformanceFailure> { let atomicity = E::driver().capabilities().dml_writes; let connection = connect(FAILED_CASE, &engine, writable()).await?; let table = create_scratch_table(&engine, connection.as_ref(), "failed_write").await?; @@ -122,7 +121,8 @@ mod tests { #[tokio::test] async fn case_reports_expectation_on_wrong_outcome() { - let outcome = failed_write_batch_outcome_matches_dml_atomicity::>().await; + let outcome = + failed_write_batch_outcome_matches_dml_atomicity(FakeEngine::::new()).await; assert!( matches!(&outcome, Err(ConformanceFailure::Expectation { case: FAILED_CASE, detail }) if detail.contains("Transactional")), @@ -132,7 +132,7 @@ mod tests { #[tokio::test] async fn failed_write_case_passes_when_a_transactional_engine_rolls_back() { - failed_write_batch_outcome_matches_dml_atomicity::>() + failed_write_batch_outcome_matches_dml_atomicity(FakeEngine::::new()) .await .unwrap(); } diff --git a/linux/crates/test-support/src/conformance/connection_suite_macro.rs b/linux/crates/test-support/src/conformance/connection_suite_macro.rs index b361e33fea..e7feab7e68 100644 --- a/linux/crates/test-support/src/conformance/connection_suite_macro.rs +++ b/linux/crates/test-support/src/conformance/connection_suite_macro.rs @@ -19,6 +19,10 @@ /// ignored: it fails when `endpoint` or `editor_transactions` disagrees /// with the driver's capabilities. /// +/// A case that has not finished `CASE_LIMIT` after its engine started +/// fails with its name, so a driver that ignores a deadline or a cancel +/// cannot hold the test run. +/// /// The example compiles every case against the test-support fakes; a /// driver's integration target supplies its own engine instead. /// @@ -147,51 +151,61 @@ macro_rules! connection_conformance_suite { ) => { $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, lists_created_table_with_primary_key_and_unique_identity, $crate::conformance::cases::metadata::lists_created_table_with_primary_key_and_unique_identity::<$engine> } $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, calls_share_one_call_options, $crate::conformance::cases::metadata::calls_share_one_call_options::<$engine> } $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, query_bound_times_out_within_deadline_plus_cancel_grace, $crate::conformance::cases::deadlines::query_bound_times_out_within_deadline_plus_cancel_grace::<$engine> } $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, editor_cancel_reports_cancelled_and_session_stays_ready, $crate::conformance::cases::cancellation::editor_cancel_reports_cancelled_and_session_stays_ready::<$engine> } $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, read_only_connection_refuses_writes, $crate::conformance::cases::read_only::read_only_connection_refuses_writes::<$engine> } $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, write_batch_commits_every_step, $crate::conformance::cases::writes::write_batch_commits_every_step::<$engine> } $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, failed_write_batch_outcome_matches_dml_atomicity, $crate::conformance::cases::writes::failed_write_batch_outcome_matches_dml_atomicity::<$engine> } $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, row_guard_mismatch_applies_nothing, $crate::conformance::cases::row_guards::row_guard_mismatch_applies_nothing::<$engine> } $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, close_is_idempotent, $crate::conformance::cases::close::close_is_idempotent::<$engine> } $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, stream_read_streams_every_row_in_bounded_batches, $crate::conformance::cases::streaming::stream_read_streams_every_row_in_bounded_batches::<$engine> } @@ -217,17 +231,27 @@ macro_rules! connection_conformance_suite { #[doc(hidden)] #[macro_export] macro_rules! __conformance_case { - (true, $name:ident, $case:expr) => { + (true, $engine:ty as $kind:ident, $name:ident, $case:expr) => { #[::tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn $name() -> ::core::result::Result<(), $crate::conformance::ConformanceFailure> { - ($case)().await + $crate::conformance::run_case( + ::core::stringify!($name), + <$engine as $crate::conformance::$kind>::start(), + $case, + ) + .await } }; - (false, $name:ident, $case:expr) => { + (false, $engine:ty as $kind:ident, $name:ident, $case:expr) => { #[::tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn $name() -> ::core::result::Result<(), $crate::conformance::ConformanceFailure> { - ($case)().await + $crate::conformance::run_case( + ::core::stringify!($name), + <$engine as $crate::conformance::$kind>::start(), + $case, + ) + .await } }; } @@ -260,11 +284,13 @@ macro_rules! __conformance_endpoint_cases { (network, $docker:tt, $engine:ty) => { $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, dropped_caller_stops_server_statement, $crate::conformance::cases::cancellation::dropped_caller_stops_server_statement::<$engine> } $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, lost_transport_reports_connection_lost, $crate::conformance::cases::liveness::lost_transport_reports_connection_lost::<$engine> } @@ -278,6 +304,7 @@ macro_rules! __conformance_pool_case { (pooled, network, $docker:tt, $engine:ty) => { $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, probe_succeeds_while_every_pool_slot_is_busy, $crate::conformance::cases::pool::probe_succeeds_while_every_network_pool_slot_is_busy::<$engine> } @@ -285,6 +312,7 @@ macro_rules! __conformance_pool_case { (pooled, file, $docker:tt, $engine:ty) => { $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, probe_succeeds_while_every_pool_slot_is_busy, $crate::conformance::cases::pool::probe_succeeds_while_every_pool_slot_is_busy::<$engine> } @@ -299,6 +327,7 @@ macro_rules! __conformance_editor_case { (true, $docker:tt, $engine:ty) => { $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, row_limit_keeps_transaction_and_later_statements, $crate::conformance::cases::row_limit::row_limit_keeps_transaction_and_later_statements::<$engine> } @@ -312,6 +341,7 @@ macro_rules! __conformance_types_case { (true, $docker:tt, $engine:ty) => { $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, unsupported_types_never_decode_as_null, $crate::conformance::cases::type_fallback::unsupported_types_never_decode_as_null::<$engine> } @@ -325,6 +355,7 @@ macro_rules! __conformance_ddl_case { (true, $docker:tt, $engine:ty) => { $crate::__conformance_case! { $docker, + $engine as ConformanceEngine, schema_changes_batch_is_not_bounded_by_statement_timeout, $crate::conformance::cases::statement_timeout::schema_changes_batch_is_not_bounded_by_statement_timeout::<$engine> } diff --git a/linux/crates/test-support/src/conformance/fake_engine.rs b/linux/crates/test-support/src/conformance/fake_engine.rs index 5ac392cd0f..eb40caf832 100644 --- a/linux/crates/test-support/src/conformance/fake_engine.rs +++ b/linux/crates/test-support/src/conformance/fake_engine.rs @@ -26,6 +26,10 @@ pub(crate) trait FakeScenario: Send + Sync + 'static { pub(crate) struct FakeEngine(PhantomData S>); impl FakeEngine { + pub(crate) fn new() -> Self { + Self(PhantomData) + } + /// Answers the setup every case does, so a scenario scripts only the /// call it is about. pub(crate) fn base_script() -> ConnectionScript { @@ -45,7 +49,7 @@ impl ConformanceEngine for FakeEngine { } async fn start() -> Result { - Ok(Self(PhantomData)) + Ok(Self::new()) } fn connect_options(&self, settings: WorkspaceSettings, liveness: LivenessPolicy) -> ConnectOptions { diff --git a/linux/crates/test-support/src/conformance/mod.rs b/linux/crates/test-support/src/conformance/mod.rs index f7b84209f8..cfaad8af75 100644 --- a/linux/crates/test-support/src/conformance/mod.rs +++ b/linux/crates/test-support/src/conformance/mod.rs @@ -18,6 +18,7 @@ mod editor_transaction_engine; mod fake_engine; mod network_conformance_engine; mod pooled_conformance_engine; +mod run_case; mod suite_endpoint; mod support; mod test_liveness; @@ -34,6 +35,7 @@ pub use connection_suite_flags::ConnectionSuiteFlags; pub use editor_transaction_engine::EditorTransactionEngine; pub use network_conformance_engine::NetworkConformanceEngine; pub use pooled_conformance_engine::PooledConformanceEngine; +pub use run_case::{CASE_LIMIT, run_case}; pub use suite_endpoint::SuiteEndpoint; pub use test_liveness::test_liveness; pub use tls_conformance_engine::TlsConformanceEngine; diff --git a/linux/crates/test-support/src/conformance/run_case.rs b/linux/crates/test-support/src/conformance/run_case.rs new file mode 100644 index 0000000000..8a9ed504c5 --- /dev/null +++ b/linux/crates/test-support/src/conformance/run_case.rs @@ -0,0 +1,64 @@ +use std::future::Future; +use std::time::Duration; + +use crate::conformance::ConformanceFailure; +use crate::conformance::support::unexpected; + +/// How long a case may run once its engine is up. Against a healthy +/// server every case takes seconds; the limit only ends a case whose +/// driver ignores a deadline or a cancel, well before the `ci` nextest +/// profile kills the test without saying why. +pub const CASE_LIMIT: Duration = Duration::from_secs(120); + +/// Starting the engine is outside `CASE_LIMIT`: pulling and booting a +/// server image can take minutes and says nothing about the driver. +pub async fn run_case( + name: &'static str, + start: impl Future>, + case: C, +) -> Result<(), ConformanceFailure> +where + C: FnOnce(E) -> F, + F: Future>, +{ + let engine = start.await?; + tokio::time::timeout(CASE_LIMIT, case(engine)) + .await + .map_err(|_| unexpected(name, format!("did not finish within {CASE_LIMIT:?}")))? +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conformance::ConformanceEngine; + use crate::conformance::cases::deadlines::query_bound_times_out_within_deadline_plus_cancel_grace; + use crate::conformance::fake_engine::{FakeEngine, FakeScenario}; + use crate::{ConnectionScript, FakeReply}; + + const CASE: &str = "query_bound_times_out_within_deadline_plus_cancel_grace"; + + struct NeverAnswers; + + impl FakeScenario for NeverAnswers { + fn script() -> ConnectionScript { + let mut script = FakeEngine::::base_script(); + script.query_bound.always(FakeReply::Hang); + script + } + } + + #[tokio::test(start_paused = true)] + async fn a_case_whose_driver_never_answers_fails_with_its_name() { + let outcome = run_case( + CASE, + FakeEngine::::start(), + query_bound_times_out_within_deadline_plus_cancel_grace, + ) + .await; + + assert!( + matches!(&outcome, Err(ConformanceFailure::Expectation { case: CASE, detail }) if detail.contains("did not finish")), + "{outcome:?}" + ); + } +} diff --git a/linux/crates/test-support/src/conformance/support.rs b/linux/crates/test-support/src/conformance/support.rs index 8194cb1f8a..a9033f1e8e 100644 --- a/linux/crates/test-support/src/conformance/support.rs +++ b/linux/crates/test-support/src/conformance/support.rs @@ -218,7 +218,8 @@ pub(crate) fn single_count(rows: &[Vec]) -> Option { } /// Ask the server, through an editor session, how many statements are -/// running, until `done` holds or `limit` passes. +/// running, until `done` holds or `limit` passes. `limit` is checked +/// between counts, so each count carries its own deadline. pub(crate) async fn wait_for_running( case: &'static str, monitor: &dyn EditorSession, @@ -228,7 +229,8 @@ pub(crate) async fn wait_for_running( ) -> Result { let give_up = Instant::now() + limit; loop { - let rows = run_completed(case, monitor, count_sql, run_options()).await?.rows(); + let counting = run_options().with_deadline(Some(CALL_DEADLINE)); + let rows = run_completed(case, monitor, count_sql, counting).await?.rows(); let Some(running) = single_count(&rows) else { return Err(ConformanceFailure::setup(format!( "the running-statement count returned {rows:?}, not one integer" @@ -246,3 +248,27 @@ pub(crate) async fn wait_for_running( tokio::time::sleep(Duration::from_millis(100)).await; } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FakeEditorSession, FakeRun, Replies}; + + #[tokio::test(start_paused = true)] + async fn wait_for_running_fails_when_a_count_never_answers() { + let mut runs = Replies::default(); + runs.always(FakeRun::until_stopped()); + let monitor = FakeEditorSession::new(runs); + + let waited = tokio::time::timeout( + CALL_DEADLINE * 2, + wait_for_running("case", &monitor, "SELECT count", |_| true, Duration::from_secs(1)), + ) + .await; + + assert!( + matches!(&waited, Ok(Err(ConformanceFailure::Expectation { case: "case", detail })) if detail.contains("TimedOut")), + "{waited:?}" + ); + } +} diff --git a/linux/crates/test-support/src/conformance/tls_suite_macro.rs b/linux/crates/test-support/src/conformance/tls_suite_macro.rs index b15d98bc4d..cfabc3c8a7 100644 --- a/linux/crates/test-support/src/conformance/tls_suite_macro.rs +++ b/linux/crates/test-support/src/conformance/tls_suite_macro.rs @@ -84,26 +84,31 @@ macro_rules! tls_conformance_suite { (engine: $engine:ty, client_identity: $identity:tt $(,)?) => { $crate::__conformance_case! { true, + $engine as TlsConformanceEngine, verify_full_with_issuing_ca_connects, $crate::conformance::cases::tls::verify_full_with_issuing_ca_connects::<$engine> } $crate::__conformance_case! { true, + $engine as TlsConformanceEngine, verify_full_with_unrelated_ca_reports_unknown_issuer, $crate::conformance::cases::tls::verify_full_with_unrelated_ca_reports_unknown_issuer::<$engine> } $crate::__conformance_case! { true, + $engine as TlsConformanceEngine, verify_full_with_wrong_server_name_reports_name_mismatch, $crate::conformance::cases::tls::verify_full_with_wrong_server_name_reports_name_mismatch::<$engine> } $crate::__conformance_case! { true, + $engine as TlsConformanceEngine, require_connects_without_ca, $crate::conformance::cases::tls::require_connects_without_ca::<$engine> } $crate::__conformance_case! { true, + $engine as TlsConformanceEngine, disable_against_tls_only_server_is_refused, $crate::conformance::cases::tls::disable_against_tls_only_server_is_refused::<$engine> } @@ -125,6 +130,7 @@ macro_rules! __tls_identity_case { (true, $engine:ty) => { $crate::__conformance_case! { true, + $engine as TlsConformanceEngine, client_identity_is_required_by_the_server, $crate::conformance::cases::tls::client_identity_is_required_by_the_server::<$engine> } diff --git a/linux/docs/testing.md b/linux/docs/testing.md index 90ef0d7f67..977e54ebc5 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -125,6 +125,8 @@ Each flag adds cases and needs its engine trait: A flag that is off emits no test. `suite_flags_match_driver_capabilities` is always emitted and never ignored: it fails when `endpoint` or `editor_transactions` disagrees with the driver's `DriverCapabilities`, so a driver cannot skip a case by declaring less. `docker: true` marks every other case `#[ignore = "requires docker"]`; SQLite passes `docker: false` and `endpoint: file`, so its suite runs in the unit suite. +Each case starts its own engine, then has two minutes (`CASE_LIMIT`) to finish. A driver that ignores a deadline or a cancel fails the case with its name instead of holding the test run. + ### TLS conformance Every network driver also runs `tls_conformance_suite!` against a TLS-only server built from `TestPki`: From 4482bddffbb942ea18778e3e58bb1d5b4f8d035e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:31:56 +0700 Subject: [PATCH 029/178] test(linux-tests): fail a row guard on a present row --- .../src/conformance/cases/row_guards.rs | 162 ++++++++++++++++-- 1 file changed, 146 insertions(+), 16 deletions(-) diff --git a/linux/crates/test-support/src/conformance/cases/row_guards.rs b/linux/crates/test-support/src/conformance/cases/row_guards.rs index a2a064ccbe..cee80fd3ff 100644 --- a/linux/crates/test-support/src/conformance/cases/row_guards.rs +++ b/linux/crates/test-support/src/conformance/cases/row_guards.rs @@ -1,6 +1,8 @@ +use tablepro_core::meta::RowIdentity; use tablepro_core::save::{ChangeSet, RowUpdate, plan_save}; +use tablepro_core::statement::RowGuard; use tablepro_core::value::Value; -use tablepro_core::{DriverError, WriteKind}; +use tablepro_core::{ColumnInfo, Connection, DriverError, TableRef, WriteBatch, WriteKind}; use crate::conformance::support::{ CALL_DEADLINE, apply, connect, count, create_scratch_table, expect, insert_sql, statement, unexpected, within, @@ -9,17 +11,20 @@ use crate::conformance::support::{ use crate::conformance::{ConformanceEngine, ConformanceFailure}; const CASE: &str = "row_guard_mismatch_applies_nothing"; +const KEPT_ID: i64 = 1; const MISSING_ID: i64 = 999; -/// Case 10: a save that edits a row which is no longer there matches no -/// row, and fails on its guard without changing anything. The save is -/// planned the way the grid plans one, so the guard is the one the app -/// would send to this engine. +/// Case 10: a save whose guard fails changes nothing. One update names +/// a row that is gone, so it matches nothing. The other names the row +/// that is there under a guard asking for one row more, so a driver +/// that applies the write despite its guard leaves the row changed. The +/// saves are planned the way the grid plans one, so the kind of guard +/// is the one the app would send to this engine. pub async fn row_guard_mismatch_applies_nothing(engine: E) -> Result<(), ConformanceFailure> { let connection = connect(CASE, &engine, writable()).await?; let table = create_scratch_table(&engine, connection.as_ref(), "row_guard").await?; let dialect = connection.dialect(); - let insert = insert_sql(dialect.as_ref(), &table, 1, "kept"); + let insert = insert_sql(dialect.as_ref(), &table, KEPT_ID, "kept"); apply(connection.as_ref(), &table, WriteKind::RowChanges, vec![insert]).await?; let columns = connection.fetch_columns(&table, within(CALL_DEADLINE)).await?; @@ -28,19 +33,26 @@ pub async fn row_guard_mismatch_applies_nothing(engine: E) .iter() .position(|column| column.name == "label") .ok_or_else(|| ConformanceFailure::setup(format!("the scratch table has no label column: {columns:?}")))?; - let changes = ChangeSet { - updates: vec![RowUpdate { - key: vec![Value::Int(MISSING_ID)], - assignments: vec![(label, Value::Text("changed".to_owned()))], - }], - ..ChangeSet::default() - }; - let plan = plan_save(dialect.as_ref(), &table, &columns, &identity, &changes).map_err(ConformanceFailure::setup)?; + let update = |id: i64| relabel(connection.as_ref(), &table, &columns, &identity, label, id); - let outcome = connection.write_batch(&plan.batch, within(CALL_DEADLINE)).await; + let missing = update(MISSING_ID)?; + let outcome = connection.write_batch(&missing, within(CALL_DEADLINE)).await; if !matches!(outcome, Err(DriverError::RowGuardFailed { actual: 0, .. })) { return Err(unexpected(CASE, format!("an update of a missing row gave {outcome:?}"))); } + + let mut present = update(KEPT_ID)?; + for step in &mut present.steps { + step.guard = one_row_more(&step.guard)?; + } + let outcome = connection.write_batch(&present, within(CALL_DEADLINE)).await; + if !matches!(outcome, Err(DriverError::RowGuardFailed { actual: 1, .. })) { + return Err(unexpected( + CASE, + format!("an update of a present row under a guard it cannot meet gave {outcome:?}"), + )); + } + let table_sql = dialect.qualify(&table); let kept = format!( "SELECT {} FROM {table_sql} WHERE {} = 'kept'", @@ -50,8 +62,126 @@ pub async fn row_guard_mismatch_applies_nothing(engine: E) let unchanged = connection.count_rows(&statement(&kept), within(CALL_DEADLINE)).await?; let rows = count(connection.as_ref(), &table).await?; expect(CASE, unchanged == 1 && rows == 1, || { - format!("after the refused update {unchanged} of {rows} rows still read 'kept'") + format!("after the refused updates {unchanged} of {rows} rows still read 'kept'") })?; connection.close().await?; Ok(()) } + +fn relabel( + connection: &dyn Connection, + table: &TableRef, + columns: &[ColumnInfo], + identity: &RowIdentity, + label: usize, + id: i64, +) -> Result { + let changes = ChangeSet { + updates: vec![RowUpdate { + key: vec![Value::Int(id)], + assignments: vec![(label, Value::Text("changed".to_owned()))], + }], + ..ChangeSet::default() + }; + let plan = plan_save(connection.dialect().as_ref(), table, columns, identity, &changes) + .map_err(ConformanceFailure::setup)?; + Ok(plan.batch) +} + +fn one_row_more(guard: &RowGuard) -> Result { + match guard { + RowGuard::AffectedExactly(rows) => Ok(RowGuard::AffectedExactly(rows.saturating_add(1))), + RowGuard::ProbeExactly { probe, rows } => Ok(RowGuard::ProbeExactly { + probe: probe.clone(), + rows: rows.saturating_add(1), + }), + RowGuard::None => Err(unexpected(CASE, "the grid planned an update with no row guard")), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::WriteReport; + use tablepro_core::column::{ColumnDefault, ColumnType}; + + use super::*; + use crate::conformance::fake_engine::{FakeEngine, FakeScenario}; + use crate::{ConnectionScript, FakeReply}; + + fn column(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: false, + primary_key: name == "id", + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn guard_failed(actual: u64) -> FakeReply { + FakeReply::err(DriverError::RowGuardFailed { + statement_index: 0, + expected: actual.saturating_add(1), + actual, + }) + } + + /// A table holding the one 'kept' row. The update of the missing row + /// fails its guard; the update of the present row answers `present` + /// and leaves `kept_after` rows reading 'kept'. + fn script(present: FakeReply, kept_after: u64) -> ConnectionScript { + let mut script = FakeEngine::<()>::base_script(); + script + .write_batch + .when_sql_contains("INSERT", FakeReply::ok(WriteReport::new(vec![Some(1)]))) + .once(guard_failed(0)) + .once(present); + script + .fetch_columns + .always(FakeReply::ok(vec![column("id"), column("label")])); + script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + })); + script + .count_rows + .when_sql_contains("'kept'", FakeReply::ok(kept_after)) + .always(FakeReply::ok(1)); + script + } + + struct RollsBack; + + impl FakeScenario for RollsBack { + fn script() -> ConnectionScript { + script(guard_failed(1), 1) + } + } + + struct IgnoresTheGuard; + + impl FakeScenario for IgnoresTheGuard { + fn script() -> ConnectionScript { + script(FakeReply::ok(WriteReport::new(vec![Some(1)])), 0) + } + } + + #[tokio::test(start_paused = true)] + async fn row_guard_case_passes_when_both_updates_are_refused() { + row_guard_mismatch_applies_nothing(FakeEngine::::new()) + .await + .unwrap(); + } + + #[tokio::test(start_paused = true)] + async fn row_guard_case_fails_when_the_present_row_is_written_despite_its_guard() { + let outcome = row_guard_mismatch_applies_nothing(FakeEngine::::new()).await; + + assert!( + matches!(&outcome, Err(ConformanceFailure::Expectation { case: CASE, detail }) if detail.contains("present row")), + "{outcome:?}" + ); + } +} From 8b8ccbede1283d4c36faab51f23512eb822690f9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:31:57 +0700 Subject: [PATCH 030/178] docs(linux): say which suite flags no capability can check --- .../src/conformance/connection_suite_macro.rs | 3 ++- linux/crates/test-support/src/conformance/mod.rs | 9 +++++++-- linux/docs/testing.md | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/linux/crates/test-support/src/conformance/connection_suite_macro.rs b/linux/crates/test-support/src/conformance/connection_suite_macro.rs index e7feab7e68..94e6ab33b3 100644 --- a/linux/crates/test-support/src/conformance/connection_suite_macro.rs +++ b/linux/crates/test-support/src/conformance/connection_suite_macro.rs @@ -17,7 +17,8 @@ /// /// `suite_flags_match_driver_capabilities` is always emitted and never /// ignored: it fails when `endpoint` or `editor_transactions` disagrees -/// with the driver's capabilities. +/// with the driver's capabilities. `pool`, `unsupported_types` and +/// `bounded_ddl` have no capability to check against. /// /// A case that has not finished `CASE_LIMIT` after its engine started /// fails with its name, so a driver that ignores a deadline or a cancel diff --git a/linux/crates/test-support/src/conformance/mod.rs b/linux/crates/test-support/src/conformance/mod.rs index cfaad8af75..19d315bff9 100644 --- a/linux/crates/test-support/src/conformance/mod.rs +++ b/linux/crates/test-support/src/conformance/mod.rs @@ -3,8 +3,13 @@ //! A driver's integration target implements an engine adapter and //! invokes `connection_conformance_suite!` and `tls_conformance_suite!` //! with flags for what the engine supports. A flag that is off emits no -//! test, and the flags themselves are checked against the driver's -//! capability table, so a case cannot be skipped by declaring less. +//! test. The flags with a capability behind them (`endpoint`, +//! `editor_transactions` and the TLS `client_identity`) are checked +//! against the driver's capability table, so those cases cannot be +//! skipped by declaring less. `pool`, `unsupported_types` and +//! `bounded_ddl` describe the server rather than the driver (MySQL and +//! MariaDB differ on `bounded_ddl` under one driver), so the adapter +//! states them and no capability can contradict them. mod bounded_ddl_engine; pub mod cases; diff --git a/linux/docs/testing.md b/linux/docs/testing.md index 977e54ebc5..7e0c5e9bac 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -123,7 +123,7 @@ Each flag adds cases and needs its engine trait: | `unsupported_types: true` | an unsupported type never decodes as NULL | `TypedConformanceEngine` | | `bounded_ddl: true` | a schema-change batch is not cut off by the statement timeout | `BoundedDdlEngine` | -A flag that is off emits no test. `suite_flags_match_driver_capabilities` is always emitted and never ignored: it fails when `endpoint` or `editor_transactions` disagrees with the driver's `DriverCapabilities`, so a driver cannot skip a case by declaring less. `docker: true` marks every other case `#[ignore = "requires docker"]`; SQLite passes `docker: false` and `endpoint: file`, so its suite runs in the unit suite. +A flag that is off emits no test. `suite_flags_match_driver_capabilities` is always emitted and never ignored: it fails when `endpoint` or `editor_transactions` disagrees with the driver's `DriverCapabilities`, so those cases cannot be skipped by declaring less. `pool`, `unsupported_types` and `bounded_ddl` have no capability behind them: they describe the server, and MySQL and MariaDB differ on `bounded_ddl` under one driver, so the adapter states them and review checks them. `docker: true` marks every other case `#[ignore = "requires docker"]`; SQLite passes `docker: false` and `endpoint: file`, so its suite runs in the unit suite. Each case starts its own engine, then has two minutes (`CASE_LIMIT`) to finish. A driver that ignores a deadline or a cancel fails the case with its name instead of holding the test run. From 1372e83edde993c413d39f4e17d84f9e5cfca642 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:31:57 +0700 Subject: [PATCH 031/178] fix(linux-tests): run a fake's close behaviour once per object --- linux/crates/test-support/src/close_race.rs | 46 +++++++++++++++++++ .../test-support/src/fake_connection.rs | 28 ++++++++++- .../test-support/src/fake_editor_session.rs | 21 ++++++++- linux/crates/test-support/src/lib.rs | 2 + 4 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 linux/crates/test-support/src/close_race.rs diff --git a/linux/crates/test-support/src/close_race.rs b/linux/crates/test-support/src/close_race.rs new file mode 100644 index 0000000000..776beb4885 --- /dev/null +++ b/linux/crates/test-support/src/close_race.rs @@ -0,0 +1,46 @@ +use std::sync::{Arc, Barrier}; +use std::thread; + +use futures::FutureExt; +use futures::future::BoxFuture; + +/// Enough rounds that two closes land inside each other's check at +/// least once when the check is not atomic. +pub(crate) const ROUNDS: usize = 2_000; + +/// Per round, how many of two closes started on two threads at the +/// same moment were still pending after one poll. Under a hanging close +/// behaviour, only a close that ran the behaviour stays pending. +pub(crate) fn pending_closes( + rounds: usize, + make: impl Fn() -> T, + close: for<'a> fn(&'a T) -> BoxFuture<'a, ()>, +) -> Vec +where + T: Send + Sync + 'static, +{ + let targets: Arc> = Arc::new((0..rounds).map(|_| make()).collect()); + let barrier = Arc::new(Barrier::new(2)); + let racers: Vec<_> = (0..2) + .map(|_| { + let targets = targets.clone(); + let barrier = barrier.clone(); + thread::spawn(move || { + targets + .iter() + .map(|target| { + barrier.wait(); + close(target).now_or_never().is_none() + }) + .collect::>() + }) + }) + .collect(); + let pending: Vec> = racers + .into_iter() + .map(|racer| racer.join().unwrap_or_default()) + .collect(); + (0..rounds) + .map(|round| pending.iter().filter(|racer| racer.get(round) == Some(&true)).count()) + .collect() +} diff --git a/linux/crates/test-support/src/fake_connection.rs b/linux/crates/test-support/src/fake_connection.rs index 0f589a4e19..b70706a73a 100644 --- a/linux/crates/test-support/src/fake_connection.rs +++ b/linux/crates/test-support/src/fake_connection.rs @@ -1,4 +1,4 @@ -use std::sync::atomic::AtomicUsize; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use async_trait::async_trait; @@ -28,6 +28,7 @@ pub struct FakeConnection { dialect: Arc, read_only: bool, close_behaviour: CloseBehaviour, + close_started: AtomicBool, link: LinkWatch, closed: CancellationToken, counters: CallCounters, @@ -40,6 +41,7 @@ impl FakeConnection { dialect: script.dialect.clone(), read_only: script.read_only, close_behaviour: script.close, + close_started: AtomicBool::new(false), script: Mutex::new(script), link: LinkWatch::new(), closed: CancellationToken::new(), @@ -375,7 +377,7 @@ impl Connection for FakeConnection { async fn close(&self) -> Result<(), DriverError> { CallCounters::record(&self.counters.close); - let first = !self.closed.is_cancelled(); + let first = !self.close_started.swap(true, Ordering::SeqCst); self.closed.cancel(); if first { self.close_behaviour.run().await; @@ -399,6 +401,7 @@ fn apply_limit(mut result: QueryResult, limit: RowLimit) -> QueryResult { mod tests { use std::time::Duration; + use futures::future::BoxFuture; use tablepro_core::LossPhase; use tablepro_core::meta::ObjectKind; use tablepro_core::statement::{Statement, StatementBuilder}; @@ -406,6 +409,7 @@ mod tests { use tokio::time::Instant; use super::*; + use crate::close_race::{ROUNDS, pending_closes}; fn select(sql: &str) -> Statement { let mut builder = StatementBuilder::new(); @@ -475,6 +479,26 @@ mod tests { assert_eq!(again.elapsed(), Duration::ZERO, "a second close waited again"); } + fn close_connection(connection: &FakeConnection) -> BoxFuture<'_, ()> { + Box::pin(async move { drop(connection.close().await) }) + } + + #[test] + fn concurrent_closes_run_the_close_behaviour_once() { + let pending = pending_closes( + ROUNDS, + || { + FakeConnection::new(ConnectionScript { + close: CloseBehaviour::Hang, + ..ConnectionScript::default() + }) + }, + close_connection, + ); + + assert!(pending.iter().all(|&hung| hung == 1), "{pending:?}"); + } + #[tokio::test(start_paused = true)] async fn a_bounded_reply_stops_at_the_deadline_and_a_late_one_does_not() { let mut script = ConnectionScript::default(); diff --git a/linux/crates/test-support/src/fake_editor_session.rs b/linux/crates/test-support/src/fake_editor_session.rs index 099c56727d..d51d1ec195 100644 --- a/linux/crates/test-support/src/fake_editor_session.rs +++ b/linux/crates/test-support/src/fake_editor_session.rs @@ -179,10 +179,10 @@ impl EditorSession for FakeEditorSession { } async fn close(&self) { - self.closes.fetch_add(1, Ordering::SeqCst); + let first = self.closes.fetch_add(1, Ordering::SeqCst) == 0; self.closing.cancel(); *lock(&self.state) = EditorSessionState::Lost; - if self.closes.load(Ordering::SeqCst) == 1 { + if first { self.close_behaviour.run().await; } self.closed.store(true, Ordering::SeqCst); @@ -195,10 +195,12 @@ mod tests { use std::time::Duration; use async_trait::async_trait; + use futures::future::BoxFuture; use tablepro_core::value::Value; use tablepro_core::{ResultEvent, RowLimit}; use super::*; + use crate::close_race::{ROUNDS, pending_closes}; #[derive(Default)] struct Discard; @@ -317,4 +319,19 @@ mod tests { assert_eq!(session.close_count(), 2); assert_eq!(session.state(), EditorSessionState::Lost); } + + fn close_session(session: &FakeEditorSession) -> BoxFuture<'_, ()> { + session.close() + } + + #[test] + fn concurrent_closes_run_the_close_behaviour_once() { + let pending = pending_closes( + ROUNDS, + || FakeEditorSession::with_close(Replies::default(), CloseBehaviour::Hang), + close_session, + ); + + assert!(pending.iter().all(|&hung| hung == 1), "{pending:?}"); + } } diff --git a/linux/crates/test-support/src/lib.rs b/linux/crates/test-support/src/lib.rs index e5b770ba83..f02954726a 100644 --- a/linux/crates/test-support/src/lib.rs +++ b/linux/crates/test-support/src/lib.rs @@ -8,6 +8,8 @@ use std::sync::{Mutex, MutexGuard, PoisonError}; mod call_counters; mod close_behaviour; +#[cfg(test)] +mod close_race; pub mod conformance; mod connection_script; mod fake_connection; From 7fc64e7c4f95ca4cc74371e48c35242ab7fcedc9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:29:23 +0700 Subject: [PATCH 032/178] fix(linux-editor): enforce the 8 MB SQL file limit while reading --- linux/CHANGELOG.md | 1 + linux/crates/app/src/ui/editor/mod.rs | 24 +-- linux/crates/app/src/ui/editor/sql_file.rs | 169 +++++++++++++++++++++ linux/po/POTFILES.in | 1 + 4 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 linux/crates/app/src/ui/editor/sql_file.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index dddc7e9cfc..352e836fb2 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -19,3 +19,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. - Format could put a space inside `+=`, `b'01'` or `@@version` and change what the statement does. +- Opening a pipe or a device as a SQL file never finished, or read past the 8 MB limit. diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 042fe38717..aaaa34f95c 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -15,6 +15,7 @@ mod run_scope; mod script_runner; mod sidebar_policy; mod significant_tokens; +mod sql_file; mod statement_cursor; #[cfg(test)] mod tests; @@ -227,7 +228,7 @@ impl SqlEditor { /// Read a dropped or chosen file and put it in the buffer: the whole /// buffer when it is empty, at the cursor otherwise. pub async fn insert_file(&self, file: gio::File, replace: bool) { - let text = match load_text(&file).await { + let text = match sql_file::load_text(&file).await { Ok(text) => text, Err(message) => { self.emit_by_name::<()>("toast", &[&message]); @@ -753,27 +754,6 @@ fn buffer_text(buffer: &impl IsA) -> String { buffer.text(&start, &end, false).to_string() } -/// The most a file may be before the editor refuses it. -/// -/// A dump can be gigabytes, and a text buffer holding one stops being -/// an editor. Refusing is better than a window that will not paint. -const MAX_SQL_FILE_BYTES: i64 = 8 * 1024 * 1024; - -/// Read a file as text off the main thread, or say why it cannot be. -async fn load_text(file: &gio::File) -> Result { - let info = file - .query_info_future("standard::size", gio::FileQueryInfoFlags::NONE, glib::Priority::DEFAULT) - .await; - if info.is_ok_and(|info| info.size() > MAX_SQL_FILE_BYTES) { - return Err(crate::i18n::gettext("That file is too large to open in the editor.")); - } - let (bytes, _) = file.load_contents_future().await.map_err(|error| { - tracing::warn!(%error, "could not read a file for the editor"); - crate::i18n::gettext("That file could not be read.") - })?; - String::from_utf8(bytes.to_vec()).map_err(|_| crate::i18n::gettext("That file is not UTF-8 text.")) -} - /// Toggle SQL line-comment (`-- `) for the lines in the buffer's /// current selection (or the cursor's line when nothing is selected). /// If every non-blank line in the range is already commented, strip diff --git a/linux/crates/app/src/ui/editor/sql_file.rs b/linux/crates/app/src/ui/editor/sql_file.rs new file mode 100644 index 0000000000..d2ca5d9aed --- /dev/null +++ b/linux/crates/app/src/ui/editor/sql_file.rs @@ -0,0 +1,169 @@ +use gtk4::glib; +use gtk4::prelude::*; + +/// The most a file may be before the editor refuses it. +/// +/// A dump can be gigabytes, and a text buffer holding one stops being +/// an editor. Refusing is better than a window that will not paint. +const MAX_SQL_FILE_BYTES: usize = 8 * 1024 * 1024; + +const READ_CHUNK_BYTES: usize = 256 * 1024; + +#[derive(Debug)] +enum ReadError { + TooLarge, + Io(glib::Error), +} + +/// Read a file as text off the main thread, or say why it cannot be. +pub async fn load_text(file: &gio::File) -> Result { + let info = file + .query_info_future( + "standard::type,standard::size", + gio::FileQueryInfoFlags::NONE, + glib::Priority::DEFAULT, + ) + .await; + if let Ok(info) = info { + // Opening a pipe waits for a writer that may never come, and a + // device has no end to read to. + if info.has_attribute(gio::FILE_ATTRIBUTE_STANDARD_TYPE) && info.file_type() == gio::FileType::Special { + return Err(crate::i18n::gettext("Only regular files can be opened in the editor.")); + } + if info.has_attribute(gio::FILE_ATTRIBUTE_STANDARD_SIZE) + && u64::try_from(info.size()).is_ok_and(|size| size > MAX_SQL_FILE_BYTES as u64) + { + return Err(too_large()); + } + } + let stream = file.read_future(glib::Priority::DEFAULT).await.map_err(unreadable)?; + let read = read_capped(stream.upcast_ref(), MAX_SQL_FILE_BYTES).await; + if let Err(error) = stream.close_future(glib::Priority::DEFAULT).await { + tracing::debug!(%error, "closing a file the editor read failed"); + } + let bytes = match read { + Ok(bytes) => bytes, + Err(ReadError::TooLarge) => return Err(too_large()), + Err(ReadError::Io(error)) => return Err(unreadable(error)), + }; + String::from_utf8(bytes).map_err(|_| crate::i18n::gettext("That file is not UTF-8 text.")) +} + +/// Read to the end of the stream, giving up once it passes `limit`. +/// +/// The size a file reports is not enough: a remote file may report +/// none, and a file can grow between being measured and being read. +async fn read_capped(stream: &gio::InputStream, limit: usize) -> Result, ReadError> { + let mut contents = Vec::new(); + loop { + let chunk = stream + .read_bytes_future(READ_CHUNK_BYTES, glib::Priority::DEFAULT) + .await + .map_err(ReadError::Io)?; + if chunk.is_empty() { + return Ok(contents); + } + if contents.len() + chunk.len() > limit { + return Err(ReadError::TooLarge); + } + contents.extend_from_slice(&chunk); + } +} + +fn too_large() -> String { + crate::i18n::gettext("That file is too large to open in the editor.") +} + +fn unreadable(error: glib::Error) -> String { + tracing::warn!(%error, "could not read a file for the editor"); + crate::i18n::gettext("That file could not be read.") +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::test_support::wait_until; + + const WAIT: Duration = Duration::from_secs(10); + + fn load(file: gio::File) -> Result { + let outcome = std::rc::Rc::new(std::cell::RefCell::new(None)); + let slot = outcome.clone(); + glib::spawn_future_local(async move { + let loaded = load_text(&file).await; + slot.replace(Some(loaded)); + }); + wait_until(WAIT, || outcome.borrow().is_some()).expect("the load to end"); + outcome.take().expect("an outcome") + } + + fn capped(bytes: Vec, limit: usize) -> Result, ReadError> { + let stream = gio::MemoryInputStream::from_bytes(&glib::Bytes::from_owned(bytes)); + let outcome = std::rc::Rc::new(std::cell::RefCell::new(None)); + let slot = outcome.clone(); + glib::spawn_future_local(async move { + let read = read_capped(stream.upcast_ref(), limit).await; + slot.replace(Some(read)); + }); + wait_until(WAIT, || outcome.borrow().is_some()).expect("the read to end"); + outcome.take().expect("an outcome") + } + + #[gtk4::test] + fn a_stream_past_the_limit_is_refused_without_a_reported_size() { + let read = capped(vec![b'x'; READ_CHUNK_BYTES * 3], READ_CHUNK_BYTES * 2); + + assert!(matches!(read, Err(ReadError::TooLarge)), "{read:?}"); + } + + #[gtk4::test] + fn a_stream_at_the_limit_is_read_whole() { + let read = capped(vec![b'x'; READ_CHUNK_BYTES * 2], READ_CHUNK_BYTES * 2).expect("the contents"); + + assert_eq!(read.len(), READ_CHUNK_BYTES * 2); + } + + #[gtk4::test] + fn a_device_is_refused_before_it_is_read() { + let refused = load(gio::File::for_path("/dev/zero")).expect_err("a refusal"); + + assert!(refused.contains("regular files"), "{refused}"); + } + + #[gtk4::test] + fn a_pipe_is_refused_without_waiting_for_a_writer() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("script.sql"); + let made = std::process::Command::new("mkfifo") + .arg(&path) + .status() + .expect("mkfifo"); + assert!(made.success()); + + let refused = load(gio::File::for_path(&path)).expect_err("a refusal"); + + assert!(refused.contains("regular files"), "{refused}"); + } + + #[gtk4::test] + fn a_file_over_the_limit_is_refused() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("dump.sql"); + std::fs::write(&path, vec![b' '; MAX_SQL_FILE_BYTES + 1]).expect("write"); + + let refused = load(gio::File::for_path(&path)).expect_err("a refusal"); + + assert!(refused.contains("too large"), "{refused}"); + } + + #[gtk4::test] + fn a_small_file_is_read_as_text() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("query.sql"); + std::fs::write(&path, "SELECT 'tên';").expect("write"); + + assert_eq!(load(gio::File::for_path(&path)).as_deref(), Ok("SELECT 'tên';")); + } +} diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 94d85cf1b4..73b0c02a80 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -80,6 +80,7 @@ crates/app/src/ui/editor/run_scope.rs crates/app/src/ui/editor/script_runner.rs crates/app/src/ui/editor/sidebar_policy.rs crates/app/src/ui/editor/significant_tokens.rs +crates/app/src/ui/editor/sql_file.rs crates/app/src/ui/editor/statement_cursor.rs crates/app/src/ui/editor/tests.rs crates/app/src/ui/editor/transaction_dirty.rs From 47e9d584556bf9c70e6f455bbbd9c7a799e26659 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:33:36 +0700 Subject: [PATCH 033/178] docs(linux): correct editor cancel, row-limit and stop claims --- linux/docs/adding-drivers.md | 17 +++++++++++------ linux/docs/drivers/mysql.md | 2 +- linux/docs/error-handling.md | 2 +- linux/docs/testing.md | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index 4424fae499..4c4a95f034 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -185,7 +185,7 @@ self.calls - The supervisor races the work against the caller's cancel, the deadline and `close`. The first one to fire sets the `StopReason` (`Cancelled`, `DeadlineElapsed`, `Shutdown`) on the request. - Dropping the caller's future is a cancel: the call keeps running on its task until the stop has settled. - After a stop the work has `LivenessPolicy::cancel_grace` (5 s) to finish. Past it the call is abandoned: the resource's abandon flag is set, a read returns `ConnectionLost`, and a write returns what its `WriteLedger` saw. -- An error the engine raised to acknowledge the stop reads as what asked for it: `Cancelled`, or `Timeout { phase: Statement, server_cancelled: true }`. +- In `run_read`, an error the engine raised to acknowledge the stop reads as what asked for it: `Cancelled`, or `Timeout { phase: Statement, server_cancelled: true }`. `run_write` passes the work's result through, so a write maps an acknowledged stop itself, as `acknowledged_stop` does in the PostgreSQL driver's `write_batch.rs` and the SQL Server driver's `cancel.rs`. Start the resource empty and fill it inside the work, as `PgCall::acquire` and `MysqlCall::acquire` do, so the deadline and the cancel cover the wait for a pool slot. Arm the engine stop once a connection is held, then check `StopRequest::is_requested` again: a stop asked for in between sent nothing. @@ -272,7 +272,8 @@ An editor tab holds one session for as long as it is open, because the state a u - It is never pooled and never reopened behind the user's back. A lost connection is `EditorSessionState::Lost`, and the tab opens a new session on its next run. - One run at a time: a second `run` fails with `EditorSessionBusy`. -- `run(sql, options, sink)` takes `UserSql` as typed and `RunOptions`: `row_limit`, `deadline` and `cancel`. Dropping the caller is a cancel. +- `run(sql, options, sink)` takes `UserSql` as typed and `RunOptions`: `row_limit`, `deadline` and `cancel`. A caller stops a run through `RunOptions::cancel`. +- Only PostgreSQL and ClickHouse arm a drop guard on that token, so a dropped caller is a cancel there. MySQL, SQL Server and SQLite await the run inline: a dropped caller sends no `KILL`, attention or interrupt, and the session reports `Running` from then on. A new driver arms the guard, as `PgEditorSession::run` does. - The app's editor splits a script with the engine's grammar and sends one statement per run, or one `GO` batch on SQL Server. A driver still runs whatever text it is given, a whole script included. - A statement the server refused ends the run with `RunEnd::Failed` in the `RunSummary`; an `Err` from `run` means the session itself failed. - `RunSummary` carries `elapsed`, the `RunEnd` (`Completed`, `Failed`, `Cancelled`, `TimedOut { server_cancelled }`) and the `TransactionState` after the run. `Unknown` is an answer of its own: the tab's close guard warns instead of guessing. @@ -285,19 +286,19 @@ A run streams `ResultEvent`s into a `ResultSink`: - `ResultSetStarted { columns }` opens a result set. - `ColumnsResolved { set_index, columns }` replaces the columns of a set whose types the driver could only learn from its rows (SQLite). - `Rows` carries a batch of rows, 256 in the shipped drivers. -- `RowLimitReached { retained }` comes once per set, when the limit is passed. +- `RowLimitReached { retained }` comes once per set, before `ResultSetFinished`. - `ResultSetFinished { retained, total_rows, truncation }` closes the set. `Truncation` is `Complete`, `ServerStopped` or `ReadPastLimit`, and `total_rows` is `None` when the driver did not read the rest. - `CommandCompleted { rows_affected }` reports a statement that returned no rows; `None` is "not reported", not zero. - `Notice` passes on a server notice or warning. -`ResultSink::accept` is awaited before the next rows are read off the wire, so a grid that cannot keep up slows the read instead of growing a queue the size of the result. `SinkFlow::ConsumerGone` ends the run with `RunEnd::Cancelled`, and the driver stops the statement on the server where the engine has a stop for it. `stream_read` uses the same sink and events, without a row limit. +`ResultSink::accept` is awaited before the next rows are read off the wire, so a grid that cannot keep up slows the read instead of growing a queue the size of the result. `SinkFlow::ConsumerGone` ends the run with `RunEnd::Cancelled`. PostgreSQL, SQL Server and ClickHouse then stop the statement on the server, and SQLite stops stepping it. MySQL sends no `KILL`: sqlx reads the rest of the result off the wire before the next statement. `stream_read` uses the same sink and events, without a row limit. ## The row-limit rule `RowLimit` wraps a `NonZeroU64`; `RowLimit::EDITOR_DEFAULT` is 10,000. - `query_bound` keeps at most the limit and reads one more row to mark the result truncated. -- An editor run keeps at most the limit in each result set, sends `RowLimitReached` once, and ends the set with `Truncation::ReadPastLimit`. Where the engine allows, the rest of the set is stopped on the server rather than read: a `FETCH` of one row past the limit (PostgreSQL), `sql_select_limit` (MySQL), `KILL QUERY` (ClickHouse). SQLite stops stepping, so the rest is never produced. Otherwise the rows past the limit are counted without being decoded (SQL Server, and MySQL where `sql_select_limit` does not apply). +- An editor run keeps at most the limit in each result set and sends `RowLimitReached` once. A set the server cut at one row past the limit ends with `Truncation::ServerStopped` and no `total_rows`; only MySQL reports it, when `sql_select_limit` stopped the set. A set read past the limit ends with `Truncation::ReadPastLimit`, on every engine. Where the engine allows, the rest of the set is stopped on the server rather than read: a `FETCH` of one row past the limit (PostgreSQL), `sql_select_limit` (MySQL), `KILL QUERY` (ClickHouse). SQLite stops stepping, so the rest is never produced. Otherwise the rows past the limit are counted without being decoded (SQL Server, and MySQL where `sql_select_limit` does not apply). - The limit never ends the script, the user's transaction or the statements after the limited one. The conformance case `row_limit_keeps_transaction_and_later_statements` checks this on every engine with editor transactions. - The driver never rewrites the user's SQL to add a `LIMIT`. - `stream_read` has no limit. @@ -344,14 +345,18 @@ tablepro_test_support::connection_conformance_suite! { unsupported_types: true, bounded_ddl: true, } +``` + +A network engine's `tls.rs` implements the TLS engine and invokes the TLS suite: +```rust tablepro_test_support::tls_conformance_suite! { engine: PgTlsEngine, client_identity: true, } ``` -The cases cover listing and identity, deadlines within the cancel grace, a cancelled editor run, a dropped caller stopping the server's statement, a lost transport, a busy pool's probe, read-only refusal, write atomicity, row guards, the row limit, unbounded schema changes, streaming in bounded batches, type fallback and every TLS mode. [testing.md](testing.md#conformance) lists them with their flags. `suite_flags_match_driver_capabilities` runs without Docker and fails when a flag disagrees with the driver's capabilities, so a case cannot be skipped by declaring less. +The cases cover listing and identity, deadlines within the cancel grace, a cancelled editor run, a dropped `query_bound` caller stopping the server's statement, a lost transport, a busy pool's probe, read-only refusal, write atomicity, row guards, the row limit, unbounded schema changes, streaming in bounded batches, type fallback and every TLS mode. [testing.md](testing.md#conformance) lists them with their flags. `suite_flags_match_driver_capabilities` runs without Docker and fails when a flag disagrees with the driver's capabilities, so a case cannot be skipped by declaring less. Behaviour the suites do not cover gets a test of its own against the real engine: the engine's stop, its error mapping, what a pooler does to it, each write outcome the ledger can produce. diff --git a/linux/docs/drivers/mysql.md b/linux/docs/drivers/mysql.md index 7380e8577a..6620e98aa8 100644 --- a/linux/docs/drivers/mysql.md +++ b/linux/docs/drivers/mysql.md @@ -101,7 +101,7 @@ Whether a write can be undone depends on the table's storage engine, not on the ## Editor sessions - The editor sends one statement per run. Text with several statements runs as the server's own multi-statement batch over the text protocol, except that mysql-client `DELIMITER` lines split it where the client would, because the server does not know the directive. -- The row limit is `SET SESSION sql_select_limit = `, sent when the limit changes. The server stops a plain `SELECT` there. A `SELECT` with a `LIMIT` of its own, or one inside a routine, is read to its end, and the rows past the limit are counted, never decoded. +- The row limit is `SET SESSION sql_select_limit = `, sent when the limit changes. The server stops a plain `SELECT` there, and the set ends with `Truncation::ServerStopped` and no total. A `SELECT` with a `LIMIT` of its own, or one inside a routine, is read to its end: the rows past the limit are counted, never decoded, and the set ends with `Truncation::ReadPastLimit` and its total. - A stop sends `KILL QUERY` on the session's own control connection and gives the statement the cancel grace to end. A statement that has not ended by then ends the session, because its answer is still on the way. - When the grid stops reading, the run ends as cancelled with no `KILL`, and sqlx reads the rest of the result off the wire before the next statement. - The transaction state after each run comes from `@@in_transaction` on MariaDB and from `performance_schema.events_transactions_current` on MySQL. On MySQL before 8.0.16, with `performance_schema` off, or for a login that cannot read it, the state is `Unknown`, and the tab's close guard warns instead of guessing. diff --git a/linux/docs/error-handling.md b/linux/docs/error-handling.md index 40cdef2089..38a19334a5 100644 --- a/linux/docs/error-handling.md +++ b/linux/docs/error-handling.md @@ -129,7 +129,7 @@ The buttons are `TlsRemedy` in `crates/app/src/ui/error_text/tls_remedy.rs`, sho - Map by the engine's code, not by message text. Where the client library keeps nothing but the text, say so in a comment and pin the text with a test. - Keep the server's diagnostics: `DriverError::reported(ServerDiagnostics)`, and `DriverError::auth(Some(diagnostics))` for a refused login. -- An error the engine raised to acknowledge our stop is the stop: `Cancelled`, or `Timeout { server_cancelled: true }`. `EngineStop::acknowledged` is where `OwnedCalls` learns which errors those are. +- An error the engine raised to acknowledge our stop is the stop: `Cancelled`, or `Timeout { server_cancelled: true }`. `EngineStop::acknowledged` is where `OwnedCalls::run_read` learns which errors those are; `run_write` passes the result through, so a write maps its own. - A `ConnectionLost` goes through `Transport::explain_closed_stream`, so an SSH master that exited is reported as that. - A dead route is reported only once a check outside the pool confirms it ([adding-drivers.md](adding-drivers.md#deciding-that-the-link-is-lost)). diff --git a/linux/docs/testing.md b/linux/docs/testing.md index 90ef0d7f67..495f4a3624 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -117,7 +117,7 @@ Each flag adds cases and needs its engine trait: | Flag | Adds | Needs | |---|---|---| -| `endpoint: network` | a dropped caller stops the server's statement; a lost transport reports `ConnectionLost` | `NetworkConformanceEngine` | +| `endpoint: network` | a dropped `query_bound` caller stops the server's statement; a lost transport reports `ConnectionLost` | `NetworkConformanceEngine` | | `pool: pooled` | a probe succeeds while every pool slot is busy | `PooledConformanceEngine` | | `editor_transactions: true` | the row limit keeps the transaction and the statements after it | `EditorTransactionEngine` | | `unsupported_types: true` | an unsupported type never decodes as NULL | `TypedConformanceEngine` | From 8dee1a9bd2082ae72967d904c045f2091d9e06f3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:33:37 +0700 Subject: [PATCH 034/178] docs(linux): warn that tokio-postgres logs editor SQL at debug --- linux/docs/error-handling.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/linux/docs/error-handling.md b/linux/docs/error-handling.md index 38a19334a5..ae18e8e053 100644 --- a/linux/docs/error-handling.md +++ b/linux/docs/error-handling.md @@ -154,6 +154,8 @@ Levels: `error!` for what the user must see or a broken contract, `warn!` for re Never log a password, a secret, a key or a bound value. sqlx's own statement logging is turned off in the MySQL and SQLite drivers, and a test in each crate (`tests/statement_logging.rs`) proves no statement text reaches the subscriber. +tokio-postgres is not filtered yet. It logs the text of every simple query, which is all editor SQL, through `log` at `debug`, and `tracing-subscriber` forwards `log` records to the same output. A development build logs at `debug` by default, so an `ALTER ROLE ... PASSWORD '...'` run in the editor reaches the journal. Until the default filter covers it, run with `RUST_LOG=debug,tokio_postgres=info`. + ## `unwrap`, `expect` and `panic!` The workspace lints deny `unwrap_used`, `expect_used`, `panic`, `todo` and `unimplemented`; `clippy.toml` allows them in tests only. Return a real error instead. The one accepted use outside tests is in a `const`, where the compiler proves the value, as `RowLimit::EDITOR_DEFAULT` does. From fd4fd89a04e91f123d53ca2b7ae9121b4cf27214 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:33:38 +0700 Subject: [PATCH 035/178] docs(linux): state the session fakes exception and drop em dashes --- linux/ARCHITECTURE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index b83f669062..a7475a0047 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -46,7 +46,7 @@ Rules, enforced by review: - Each `drivers/` crate depends on `core`, and on `net` when it speaks TLS or dials TCP. **Drivers never depend on each other, on `session` or on `ssh`.** - `session` depends on `core`, `net`, `ssh` and `storage`, and on no driver: it reaches drivers through `DriverRegistry`. - `app` depends on all of them. It is the only crate that names every driver. -- `test-support` and `test-fixtures` are dev-dependencies only. +- `test-support` and `test-fixtures` are dev-dependencies, with one exception: `session` takes `test-support` as an optional dependency behind its `fakes` feature, which only `app`'s dev-dependencies turn on. Consequences: @@ -81,8 +81,8 @@ Two runtimes coexist: Bridging uses Relm4's built-in primitives instead of hand-rolled channels: -- `sender.command(move |out, shutdown| shutdown.register(async move { ... out.send(...) }).drop_on_shutdown())` — a component-scoped tokio task that cancels when the component drops. The `out` sender feeds `CmdOutput` back into the component's update loop on the GTK thread. Used for every per-tab fetch (browse rows, schema introspection, save transaction). -- `relm4::spawn(async move { ... })` — fire-and-forget tokio task with no component lifetime tie. Used for storage writes (`touch_last_opened`, `query_history::record`, column-width persistence) and other side-effect work. +- `sender.command(move |out, shutdown| shutdown.register(async move { ... out.send(...) }).drop_on_shutdown())`: a component-scoped tokio task that cancels when the component drops. The `out` sender feeds `CmdOutput` back into the component's update loop on the GTK thread. Used for every per-tab fetch (browse rows, schema introspection, save transaction). +- `relm4::spawn(async move { ... })`: a fire-and-forget tokio task with no component lifetime tie. Used for storage writes (`touch_last_opened`, `query_history::record`, column-width persistence) and other side-effect work. - `sender.input(AppMsg::...)` from inside an async block routes back into the component's `update` on the GTK thread. Combined with `sender.command`, this is how a "fetch-then-render" round trip lands its result on the right widget. `main.rs` builds a tiny `tokio::runtime::Builder::new_multi_thread().worker_threads(1)` runtime exclusively to `block_on` the history-DB init / prune at startup, then `shutdown_timeout`s it before `RelmApp::run` takes over. The query-history sqlx pool is stored in a `OnceLock` and reused from Relm4's runtime afterward. @@ -225,7 +225,7 @@ Both registries live in `thread_local!` `RefCell>` because relm A `Table` tab owns BOTH a row tracker and a DDL tracker against the same UUID. `close_workspace_tab_by_id` closes both registries; the close-with-pending dialog ORs both `has_pending()` flags and may dispatch up to two save transactions, gated through a `close_after_save: HashMap` counter. -The Structure tab is **snapshot + diff**, not per-op log. `original_*` snapshots capture the load-time schema; the diff against the live model produces ops via `sql_ddl::diff_to_ops`. Discard restores the snapshot. There is no per-op undo — Discard is the only restore point. The tracker just caches the most recent diff so out-of-band callers (close prompt, save dispatcher) read the same op list without re-deriving. +The Structure tab is **snapshot + diff**, not per-op log. `original_*` snapshots capture the load-time schema; the diff against the live model produces ops via `sql_ddl::diff_to_ops`. Discard restores the snapshot. There is no per-op undo: Discard is the only restore point. The tracker just caches the most recent diff so out-of-band callers (close prompt, save dispatcher) read the same op list without re-deriving. ## Persistence From f1044ac36b7aa2cdafaf31db22a0e3343d414ce4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:41:45 +0700 Subject: [PATCH 036/178] fix(linux-mssql): read money exactly in the editor and query results --- linux/CHANGELOG.md | 1 + linux/crates/drivers/mssql/src/catalog/mod.rs | 16 ++-- linux/crates/drivers/mssql/src/decode.rs | 86 +++++++++++++++++-- .../drivers/mssql/tests/integration/decode.rs | 25 +++++- 4 files changed, 110 insertions(+), 18 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 5aa8e0614c..55fabb8d54 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -18,3 +18,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. +- SQL Server money and smallmoney in the SQL editor and query results showed as approximate floats. diff --git a/linux/crates/drivers/mssql/src/catalog/mod.rs b/linux/crates/drivers/mssql/src/catalog/mod.rs index 4647009080..ac5f77f3b4 100644 --- a/linux/crates/drivers/mssql/src/catalog/mod.rs +++ b/linux/crates/drivers/mssql/src/catalog/mod.rs @@ -108,8 +108,8 @@ pub(crate) fn result_column(column: &Column) -> ResultColumn { TdsType::Int4 | TdsType::Intn => integer(IntegerWidth::W32, true), TdsType::Int8 => integer(IntegerWidth::W64, true), TdsType::Float4 => ColumnKind::Float(FloatWidth::F32), - // tiberius reads MONEY as a float, so here it is one. - TdsType::Float8 | TdsType::Floatn | TdsType::Money | TdsType::Money4 => ColumnKind::Float(FloatWidth::F64), + TdsType::Float8 | TdsType::Floatn => ColumnKind::Float(FloatWidth::F64), + TdsType::Money | TdsType::Money4 => ColumnKind::Money { scale: Some(4) }, TdsType::Decimaln | TdsType::Numericn => ColumnKind::Decimal { precision: None, scale: None, @@ -143,14 +143,10 @@ pub(crate) fn result_column(column: &Column) -> ResultColumn { TdsType::SSVariant => ColumnKind::Variant, TdsType::Null => ColumnKind::Other, }; - let name = match tds { - TdsType::Money | TdsType::Money4 => format!("{} (approximate)", type_label(tds)), - other => type_label(other).to_owned(), - }; ResultColumn::new( column.name(), ColumnType { - name, + name: type_label(tds).to_owned(), kind, catalog: None, dynamic_storage: false, @@ -255,11 +251,11 @@ mod tests { } #[test] - fn editor_money_is_labelled_approximate() { + fn editor_money_is_money_of_scale_4() { let money = result_column(&Column::new("price".to_owned(), TdsType::Money)); - assert_eq!(money.column_type.name, "money (approximate)"); - assert_eq!(money.column_type.kind, ColumnKind::Float(FloatWidth::F64)); + assert_eq!(money.column_type.name, "money"); + assert_eq!(money.column_type.kind, ColumnKind::Money { scale: Some(4) }); assert_eq!( result_column(&Column::new("v".to_owned(), TdsType::SSVariant)) .column_type diff --git a/linux/crates/drivers/mssql/src/decode.rs b/linux/crates/drivers/mssql/src/decode.rs index 18457b8c2d..2056e4bcd5 100644 --- a/linux/crates/drivers/mssql/src/decode.rs +++ b/linux/crates/drivers/mssql/src/decode.rs @@ -1,22 +1,30 @@ use chrono::{DateTime, Days, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime}; use tablepro_core::value::{OffsetTimestamp, SqlDecimal, SqlTime, Temporal, UndecodableReason, UndecodedValue, Value}; use tiberius::time::{Date, DateTime as TdsDateTime, DateTime2, DateTimeOffset, SmallDateTime, Time}; -use tiberius::{ColumnData, Row}; +use tiberius::{ColumnData, ColumnType as TdsType, Row}; use crate::catalog::type_label; const NANOS_PER_SECOND: u64 = 1_000_000_000; +/// MONEY and SMALLMONEY carry ten-thousandths on the wire. +const MONEY_SCALE: u32 = 4; +const MONEY_UNITS_PER_ONE: f64 = 10_000.0; + +/// 2^53: past it, neighbouring integers share a float. +const EXACT_FLOAT_INTEGERS: f64 = 9_007_199_254_740_992.0; + /// Every cell of a row, in column order. pub(crate) fn decode_row(row: &Row) -> Vec { row.cells() - .map(|(column, data)| decode_cell(data, type_label(column.column_type()))) + .map(|(column, data)| decode_cell(data, column.column_type())) .collect() } /// One cell, exactly. A NULL is the only thing that reads as `Null`; a /// value the driver cannot represent says so instead. -pub(crate) fn decode_cell(data: &ColumnData<'static>, type_name: &str) -> Value { +pub(crate) fn decode_cell(data: &ColumnData<'static>, wire_type: TdsType) -> Value { + let type_name = type_label(wire_type); let decoded = match data { ColumnData::Bit(value) => value.map(Value::Bool), ColumnData::U8(value) => value.map(|integer| Value::Int(i64::from(integer))), @@ -24,9 +32,10 @@ pub(crate) fn decode_cell(data: &ColumnData<'static>, type_name: &str) -> Value ColumnData::I32(value) => value.map(|integer| Value::Int(i64::from(integer))), ColumnData::I64(value) => value.map(Value::Int), ColumnData::F32(value) => value.map(Value::Float32), - // MONEY arrives here too: tiberius reads it as a float. Browse - // reads money columns through a decimal cast instead. - ColumnData::F64(value) => value.map(Value::Float64), + ColumnData::F64(value) => value.map(|real| match wire_type { + TdsType::Money | TdsType::Money4 => money(real, type_name), + _ => Value::Float64(real), + }), ColumnData::String(value) => value.as_ref().map(|text| Value::Text(text.to_string())), ColumnData::Guid(value) => value.map(Value::Uuid), // CLR types such as geography and hierarchyid arrive as their @@ -79,6 +88,33 @@ pub(crate) fn decode_cell(data: &ColumnData<'static>, type_name: &str) -> Value decoded.unwrap_or(Value::Null) } +/// tiberius reads MONEY into a float, dividing the scaled integer on the +/// wire by 10^4. Where only one integer divides to that float, it is the +/// value exactly; past about 5.5 * 10^11 two can, and the cell says so +/// rather than pick one. +fn money(real: f64, type_name: &str) -> Value { + match money_units(real) { + Some(units) => Value::Decimal(SqlDecimal::from_scaled_i128(i128::from(units), MONEY_SCALE)), + None => Value::Undecodable(Box::new(UndecodedValue { + type_name: type_name.to_owned(), + reason: UndecodableReason::OutOfRange, + })), + } +} + +/// The scaled integer that tiberius turned into `real`, when no other +/// one turns into the same float. +fn money_units(real: f64) -> Option { + let estimate = (real * MONEY_UNITS_PER_ONE).round(); + if !estimate.is_finite() || estimate.abs() >= EXACT_FLOAT_INTEGERS { + return None; + } + let estimate = estimate as i64; + let reads_as = |units: i64| units as f64 / MONEY_UNITS_PER_ONE == real; + let units = (estimate - 2..=estimate + 2).find(|units| reads_as(*units))?; + (!reads_as(units - 1) && !reads_as(units + 1)).then_some(units) +} + fn temporal(value: Option, type_name: &str) -> Value { value.unwrap_or_else(|| { Value::Undecodable(Box::new(UndecodedValue { @@ -154,7 +190,16 @@ mod tests { use super::*; fn decode(data: ColumnData<'static>) -> Value { - decode_cell(&data, "test") + decode_cell(&data, TdsType::Null) + } + + /// MONEY as tiberius hands it over: the wire's integer over 10^4. + fn money_as_read(units: i64) -> Value { + decode_cell(&ColumnData::F64(Some(units as f64 / 10_000.0)), TdsType::Money) + } + + fn exact_money(units: i64) -> Value { + Value::Decimal(SqlDecimal::from_scaled_i128(i128::from(units), 4)) } /// 2024-06-15 counted in days from 0001-01-01. @@ -215,6 +260,33 @@ mod tests { assert_eq!(min, Value::Decimal("-922337203685477.5808".parse().unwrap())); } + #[test] + fn money_through_the_editor_is_exact_below_2_pow_39() { + // 549755813887.9999, the last value before floats grow coarser + // than a ten-thousandth. + let widest = 5_497_558_138_879_999; + for units in [0, 1, -1, 12_345, 2_147_483_647, -2_147_483_648, widest, -widest] { + assert_eq!(money_as_read(units), exact_money(units), "{units}"); + } + assert_eq!(decode_cell(&ColumnData::F64(None), TdsType::Money4), Value::Null); + assert_eq!(decode(ColumnData::F64(Some(0.1))), Value::Float64(0.1)); + } + + #[test] + fn money_too_wide_for_a_float_is_never_a_wrong_decimal() { + let near_the_ends = (i64::MAX - 1_000..=i64::MAX).chain(i64::MIN..=i64::MIN + 1_000); + let ambiguous = 8_000_000_000_000_000..8_000_000_000_001_000; + for units in near_the_ends.chain(ambiguous) { + let decoded = money_as_read(units); + assert!( + decoded == exact_money(units) + || matches!(&decoded, Value::Undecodable(undecoded) if undecoded.reason == UndecodableReason::OutOfRange), + "{units}: {decoded:?}" + ); + } + assert!(matches!(money_as_read(i64::MAX), Value::Undecodable(_))); + } + #[test] fn datetimeoffset_plus_0530_keeps_its_offset() { let utc_time = Time::new((8 * 3600 + 15 * 60) * 10_000_000 + 1_234_567, 7); diff --git a/linux/crates/drivers/mssql/tests/integration/decode.rs b/linux/crates/drivers/mssql/tests/integration/decode.rs index 18bad3d3c9..28753336be 100644 --- a/linux/crates/drivers/mssql/tests/integration/decode.rs +++ b/linux/crates/drivers/mssql/tests/integration/decode.rs @@ -5,7 +5,7 @@ use chrono::DateTime; use tablepro_core::value::{OffsetTimestamp, SqlDecimal, Temporal}; use tablepro_core::{Connection, Value}; -use crate::support::{browse, columns, connect, dbo, insert_one, rows, scalar, setup, start, write}; +use crate::support::{browse, columns, connect, dbo, insert_one, rows, run_ok, scalar, session, setup, start, write}; fn decimal(text: &str) -> Value { Value::Decimal(text.parse::().expect("a decimal")) @@ -101,6 +101,29 @@ async fn money_max_min_exact() { .await; } +/// The editor and plain queries read MONEY without the decimal cast +/// browse uses, through the float tiberius makes of it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn money_in_the_editor_and_queries_reads_exactly() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let session = session(connection.as_ref()).await; + let sql = "SELECT CAST(123456789.1234 AS money), CAST(-214748.3648 AS smallmoney), \ + CAST(549755813887.9999 AS money), CAST(NULL AS money), CAST(922337203685477.5807 AS money)"; + + let edited = run_ok(session.as_ref(), sql).await.rows().remove(0); + let queried = rows(connection.as_ref(), sql).await.remove(0); + + for row in [edited, queried] { + assert_eq!(row[0], decimal("123456789.1234")); + assert_eq!(row[1], decimal("-214748.3648")); + assert_eq!(row[2], decimal("549755813887.9999")); + assert_eq!(row[3], Value::Null); + assert!(matches!(&row[4], Value::Undecodable(_)), "{:?}", row[4]); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn smallmoney() { From eb07487ad75dc4ba5466d9d3b7ac38d9d810f138 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:41:46 +0700 Subject: [PATCH 037/178] fix(linux-mssql): bind char and varchar keys as varchar so saves seek --- linux/CHANGELOG.md | 1 + .../mssql/src/catalog/mssql_column_row.rs | 10 ++- .../mssql/src/catalog/sql_server_type.rs | 72 +++++++++++++++++-- linux/crates/drivers/mssql/src/dialect.rs | 46 ++++++++++++ .../mssql/tests/integration/write_batch.rs | 46 ++++++++++-- 5 files changed, 164 insertions(+), 11 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 55fabb8d54..df3aad295c 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -19,3 +19,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. - SQL Server money and smallmoney in the SQL editor and query results showed as approximate floats. +- Saving a SQL Server row with a char or varchar key locked the whole table until the save ended. diff --git a/linux/crates/drivers/mssql/src/catalog/mssql_column_row.rs b/linux/crates/drivers/mssql/src/catalog/mssql_column_row.rs index 2db4d683e1..2f5814a79c 100644 --- a/linux/crates/drivers/mssql/src/catalog/mssql_column_row.rs +++ b/linux/crates/drivers/mssql/src/catalog/mssql_column_row.rs @@ -9,7 +9,8 @@ use super::sql_server_type::{CatalogFacts, SqlServerType}; /// An alias type is classified by the system type it is stored as, /// which `TYPE_NAME(system_type_id)` names; a CLR type keeps its own /// name. A collation is reported only where it differs from the -/// database's, which is the default a column takes. +/// database's, which is the default a column takes, and the last column +/// says whether its code page differs too. pub(crate) const COLUMNS_SQL: &str = "SELECT \ c.name, \ t.name, \ @@ -34,7 +35,10 @@ pub(crate) const COLUMNS_SQL: &str = "SELECT \ THEN c.collation_name END, \ CAST(c.is_sparse AS int), \ cc.definition, \ - CAST(ISNULL(cc.is_persisted, 0) AS int) \ + CAST(ISNULL(cc.is_persisted, 0) AS int), \ + CAST(CASE WHEN COLLATIONPROPERTY(c.collation_name, 'CodePage') \ + <> COLLATIONPROPERTY(CAST(DATABASEPROPERTYEX(DB_NAME(), 'Collation') AS nvarchar(128)), 'CodePage') \ + THEN 1 ELSE 0 END AS int) \ FROM sys.columns c \ JOIN sys.types t ON t.user_type_id = c.user_type_id \ LEFT JOIN sys.default_constraints dc ON dc.object_id = c.default_object_id \ @@ -85,6 +89,7 @@ impl MssqlColumnRow { max_length: cells.int(7).unwrap_or(0), precision: cells.int(8).unwrap_or(0), scale: cells.int(9).unwrap_or(0), + foreign_code_page: cells.flag(21), }); Ok(Self { name: cells.required_text(0)?, @@ -136,6 +141,7 @@ mod tests { Value::Int(0), Value::Null, Value::Int(0), + Value::Int(0), ]; let row = MssqlColumnRow::from_values(&values).unwrap(); diff --git a/linux/crates/drivers/mssql/src/catalog/sql_server_type.rs b/linux/crates/drivers/mssql/src/catalog/sql_server_type.rs index 3571b5e87b..726f613639 100644 --- a/linux/crates/drivers/mssql/src/catalog/sql_server_type.rs +++ b/linux/crates/drivers/mssql/src/catalog/sql_server_type.rs @@ -26,6 +26,9 @@ pub(crate) struct SqlServerType { /// the server, or an alias typed by name. It is read as the server's /// text. opaque: bool, + /// A character type whose collation has another code page than the + /// database's. + foreign_code_page: bool, } /// What the app makes of a type. @@ -100,6 +103,7 @@ impl SqlServerType { scale: u32::try_from(facts.scale).ok(), alias, opaque: facts.user_clr, + foreign_code_page: facts.foreign_code_page, } } @@ -131,6 +135,7 @@ impl SqlServerType { scale: None, alias: Some(text.to_owned()), opaque: true, + foreign_code_page: false, }; } let arguments: Vec<&str> = arguments @@ -155,14 +160,20 @@ impl SqlServerType { scale, alias: None, opaque: false, + foreign_code_page: false, } } /// The type as DDL spells it, modifiers included. pub(crate) fn declaration(&self) -> String { - if let Some(alias) = &self.alias { - return alias.clone(); + match &self.alias { + Some(alias) => alias.clone(), + None => self.system_declaration(), } + } + + /// The system type values are stored as, modifiers included. + fn system_declaration(&self) -> String { let base = &self.base; match base.as_str() { "char" | "varchar" | "nchar" | "nvarchar" | "binary" | "varbinary" => match self.length { @@ -189,15 +200,21 @@ impl SqlServerType { /// The type a bound text parameter is cast to. A length would /// truncate silently, so text casts to its max form, while a decimal /// keeps its digits because a bare `decimal` means `decimal(18,0)`. + /// An alias casts to the system type under it, which the server + /// converts to the alias on the way in. A varchar in another code + /// page than the database's stays nvarchar: a cast would go through + /// the database's code page and could turn one character into + /// another. pub(crate) fn cast_target(&self) -> String { - if self.alias.is_some() { + if self.opaque { return self.declaration(); } match self.base.as_str() { + "char" | "varchar" | "text" if self.foreign_code_page => "nvarchar(max)".to_owned(), "char" | "varchar" | "text" => "varchar(max)".to_owned(), "nchar" | "nvarchar" | "ntext" | "sysname" => "nvarchar(max)".to_owned(), "binary" | "varbinary" | "image" => "varbinary(max)".to_owned(), - _ => self.declaration(), + _ => self.system_declaration(), } } @@ -333,6 +350,7 @@ pub(crate) struct CatalogFacts<'a> { pub(crate) max_length: i64, pub(crate) precision: i64, pub(crate) scale: i64, + pub(crate) foreign_code_page: bool, } fn bracket(name: &str) -> String { @@ -343,6 +361,21 @@ fn bracket(name: &str) -> String { mod tests { use super::*; + fn facts(base: &str, max_length: i64) -> CatalogFacts<'_> { + CatalogFacts { + type_name: base, + base_type: base, + type_schema: Some("sys"), + user_defined: false, + alias: false, + user_clr: false, + max_length, + precision: 0, + scale: 0, + foreign_code_page: false, + } + } + fn system(base: &str, max_length: i64, precision: i64, scale: i64) -> SqlServerType { SqlServerType::from_catalog(CatalogFacts { type_name: base, @@ -354,6 +387,7 @@ mod tests { max_length, precision, scale, + foreign_code_page: false, }) } @@ -397,6 +431,7 @@ mod tests { max_length: 40, precision: 0, scale: 0, + foreign_code_page: false, }); assert_eq!(phone.declaration(), "[dbo].[Phone]"); assert_eq!( @@ -415,6 +450,7 @@ mod tests { max_length: 256, precision: 0, scale: 0, + foreign_code_page: false, }); assert_eq!(sysname.declaration(), "sysname"); assert_eq!( @@ -437,6 +473,7 @@ mod tests { max_length: 64, precision: 0, scale: 0, + foreign_code_page: false, }); assert_eq!(clr.declaration(), "[geo].[Point]"); @@ -490,4 +527,31 @@ mod tests { assert_eq!(system("decimal", 9, 10, 2).cast_target(), "decimal(10,2)"); assert_eq!(system("hierarchyid", 892, 0, 0).cast_target(), "hierarchyid"); } + + #[test] + fn a_varchar_in_another_code_page_and_an_alias_cast_to_what_holds_them() { + let greek = SqlServerType::from_catalog(CatalogFacts { + foreign_code_page: true, + ..facts("varchar", 10) + }); + let code = SqlServerType::from_catalog(CatalogFacts { + type_name: "Code", + type_schema: Some("dbo"), + user_defined: true, + alias: true, + ..facts("varchar", 10) + }); + let stamp = SqlServerType::from_catalog(CatalogFacts { + type_name: "Stamp", + type_schema: Some("dbo"), + user_defined: true, + alias: true, + ..facts("datetime", 8) + }); + + assert_eq!(greek.cast_target(), "nvarchar(max)"); + assert_eq!(code.declaration(), "[dbo].[Code]"); + assert_eq!(code.cast_target(), "varchar(max)"); + assert_eq!(stamp.cast_target(), "datetime"); + } } diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index f5310946f3..3a6d729cdc 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -75,6 +75,9 @@ impl SqlDialect for MssqlDialect { Some(legacy) => format!("CAST({marker} AS {legacy})"), None => marker, }, + (ParamType::Text | ParamType::FixedText, BindTarget::Column(column)) if non_unicode(column) => { + format!("CAST({marker} AS {NON_UNICODE_TEXT})") + } _ => marker, }; Ok(Placeholder::bound(sql, BoundParam::new(value, param_type)?)) @@ -287,6 +290,24 @@ fn legacy_datetime(column: &ColumnInfo) -> Option<&'static str> { .find(|legacy| declared.eq_ignore_ascii_case(legacy)) } +/// What `SqlServerType::cast_target` gives a char or varchar column whose +/// code page is the database's. +const NON_UNICODE_TEXT: &str = "varchar(max)"; + +/// tiberius sends text as nvarchar, which outranks varchar, so +/// `[code] = @P1` converts the column. Under a SQL_* collation that +/// cannot seek, and the save probe's UPDLOCK, HOLDLOCK then scans and +/// locks the whole table until commit. Cast to varchar, the parameter +/// meets the column as its index has it. +fn non_unicode(column: &ColumnInfo) -> bool { + matches!(column.column_type.kind, ColumnKind::Text { .. }) + && column + .column_type + .catalog + .as_ref() + .is_some_and(|catalog| catalog.cast_target.as_sql().eq_ignore_ascii_case(NON_UNICODE_TEXT)) +} + /// The parameter type a column takes. fn param_type_for(column: &ColumnInfo) -> Result { let column_type = &column.column_type; @@ -384,6 +405,31 @@ mod tests { ); } + #[test] + fn text_for_a_varchar_column_is_cast_so_the_column_can_seek() { + let text = || Value::Text("k1".to_owned()); + let mut foreign = column("varchar(10)"); + if let Some(catalog) = foreign.column_type.catalog.as_mut() { + catalog.cast_target = SqlTypeExpr::from_catalog_text("nvarchar(max)"); + } + + assert_eq!(bound("varchar(10)", text()).sql, "CAST(@P1 AS varchar(max))"); + assert_eq!(bound("char(4)", text()).sql, "CAST(@P1 AS varchar(max))"); + assert_eq!(bound("varchar(10)", Value::Null).sql, "CAST(@P1 AS varchar(max))"); + assert_eq!(bound("nvarchar(10)", text()).sql, "@P1"); + assert_eq!( + dialect() + .placeholder(0, text(), BindTarget::Column(&foreign)) + .unwrap() + .sql, + "@P1" + ); + assert_eq!( + dialect().placeholder(0, text(), BindTarget::Pattern).unwrap().sql, + "@P1" + ); + } + #[test] fn markers_count_from_the_params_already_bound() { let placeholder = dialect() diff --git a/linux/crates/drivers/mssql/tests/integration/write_batch.rs b/linux/crates/drivers/mssql/tests/integration/write_batch.rs index 63fc3f1955..ff481dd2d4 100644 --- a/linux/crates/drivers/mssql/tests/integration/write_batch.rs +++ b/linux/crates/drivers/mssql/tests/integration/write_batch.rs @@ -10,9 +10,9 @@ use tablepro_core::{CallOptions, DriverError, ErrorCategory, Value, WriteBatch}; use tokio::time::Instant; use tokio_util::sync::CancellationToken; -use crate::support::{CALL, batch, browse, columns, connect, dbo, scalar, setup, start, within}; +use crate::support::{CALL, batch, browse, columns, connect, dbo, run_ok, scalar, session, setup, start, within}; -async fn update_label(connection: &dyn tablepro_core::Connection, table: &TableRef, id: i64) -> WriteBatch { +async fn update_label(connection: &dyn tablepro_core::Connection, table: &TableRef, key: Value) -> WriteBatch { let columns = columns(connection, table).await; let identity = connection.row_identity(table, within(CALL)).await.expect("an identity"); let label = columns @@ -21,7 +21,7 @@ async fn update_label(connection: &dyn tablepro_core::Connection, table: &TableR .expect("a label column"); let changes = ChangeSet { updates: vec![RowUpdate { - key: vec![Value::Int(id)], + key: vec![key], assignments: vec![(label, Value::Text("changed".to_owned()))], }], ..ChangeSet::default() @@ -111,6 +111,42 @@ async fn datetime_key_with_a_third_of_a_millisecond_saves() { ); } +/// Under a SQL_* collation an nvarchar parameter cannot seek a varchar +/// key, so the probe's UPDLOCK, HOLDLOCK would scan into a row another +/// transaction holds and wait for it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn varchar_key_save_locks_only_its_own_row() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE dbo.codes (code varchar(10) COLLATE SQL_Latin1_General_CP1_CI_AS PRIMARY KEY, \ + label nvarchar(50) NOT NULL)", + "INSERT INTO dbo.codes VALUES ('k1', N'one'), ('k2', N'two'), ('k3', N'three'), ('k4', N'four'), \ + ('k5', N'five')", + ], + ) + .await; + let holder = session(connection.as_ref()).await; + run_ok( + holder.as_ref(), + "BEGIN TRANSACTION; UPDATE dbo.codes SET label = N'held' WHERE code = 'k5'", + ) + .await; + let save = update_label(connection.as_ref(), &dbo("codes"), Value::Text("k1".to_owned())).await; + + let saved = connection.write_batch(&save, within(Duration::from_secs(5))).await; + run_ok(holder.as_ref(), "ROLLBACK").await; + + assert!(saved.is_ok(), "{saved:?}"); + assert_eq!( + scalar(connection.as_ref(), "SELECT label FROM dbo.codes WHERE code = 'k1'").await, + Value::Text("changed".to_owned()) + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn after_update_trigger_saves_without_false_conflict() { @@ -129,7 +165,7 @@ async fn after_update_trigger_saves_without_false_conflict() { .await; let table = dbo("items"); - let save = update_label(connection.as_ref(), &table, 1).await; + let save = update_label(connection.as_ref(), &table, Value::Int(1)).await; connection.write_batch(&save, within(CALL)).await.expect("the save"); assert_eq!( @@ -156,7 +192,7 @@ async fn probe_mismatch_rolls_back() { ) .await; let table = dbo("items"); - let mut save = update_label(connection.as_ref(), &table, 999).await; + let mut save = update_label(connection.as_ref(), &table, Value::Int(999)).await; let first = crate::support::statement("INSERT INTO dbo.items VALUES (2, N'new')"); save.steps .insert(0, tablepro_core::statement::GuardedStatement::unguarded(first)); From 55ef9489afe39420c755558df2ae1fb0fcd359c1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:41:47 +0700 Subject: [PATCH 038/178] test(linux-mssql): pin a stop after any answer as mid-token --- .../drivers/mssql/src/in_band_request.rs | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/linux/crates/drivers/mssql/src/in_band_request.rs b/linux/crates/drivers/mssql/src/in_band_request.rs index 204988c715..63d5ea2186 100644 --- a/linux/crates/drivers/mssql/src/in_band_request.rs +++ b/linux/crates/drivers/mssql/src/in_band_request.rs @@ -184,7 +184,10 @@ impl Watch { } /// A request still waiting has consumed nothing if nothing arrived, - /// and may be inside a token if anything did. + /// and may be inside a token if anything did. Nothing new since the + /// last row proves less: the server fills each packet whatever token + /// that splits, and tiberius takes a token in pieces as packets + /// come, so the row after it may be half read already. fn halt_while_waiting(&self) -> Halt { match self.tally.bytes() == self.sent_at.load(Ordering::SeqCst) { true => Halt::Boundary, @@ -309,8 +312,10 @@ mod tests { use std::time::Duration; use tablepro_core::StopReason; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; use super::*; + use crate::transport_io::TransportIo; fn watch(tally: &ReadTally) -> Watch { Watch::new( @@ -362,6 +367,28 @@ mod tests { assert!(matches!(outcome, Err(Interrupt::Stopped(Halt::Boundary)))); } + /// The bytes stand for the first half of a row, split across two + /// packets, which the decoder has already taken. + #[tokio::test(start_paused = true)] + async fn a_request_that_went_quiet_after_any_answer_may_be_mid_token() { + let (client, mut server) = tokio::io::duplex(64); + let tally = ReadTally::new(); + let mut io = TransportIo::new(Box::new(client), tally.clone()); + let watch = watch(&tally); + let request = watch.request().clone(); + + let outcome = watch + .start(async move { + server.write_all(b"half a row").await.unwrap(); + io.read_exact(&mut [0u8; 10]).await.unwrap(); + request.request(StopReason::Cancelled); + std::future::pending::>().await + }) + .await; + + assert!(matches!(outcome, Err(Interrupt::Stopped(Halt::MidToken)))); + } + #[tokio::test] async fn a_request_that_ends_while_stopping_finished_on_its_own() { let watch = watch(&ReadTally::new()); From 6d38e445579fdba4c990fc01e4bce0d82b651f5d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:41:48 +0700 Subject: [PATCH 039/178] fix(linux-sqlite): let Stop end a statement waiting for a lock --- linux/CHANGELOG.md | 1 + linux/crates/drivers/sqlite/Cargo.toml | 7 +- linux/crates/drivers/sqlite/src/autocommit.rs | 5 +- linux/crates/drivers/sqlite/src/connection.rs | 10 +- .../drivers/sqlite/src/editor_session.rs | 11 +- linux/crates/drivers/sqlite/src/interrupt.rs | 36 ++++- linux/crates/drivers/sqlite/src/lib.rs | 1 + linux/crates/drivers/sqlite/src/lock_wait.rs | 146 ++++++++++++++++++ .../crates/drivers/sqlite/src/write_batch.rs | 12 +- .../sqlite/tests/integration/lock_wait.rs | 104 +++++++++++++ .../drivers/sqlite/tests/integration/main.rs | 2 + 11 files changed, 312 insertions(+), 23 deletions(-) create mode 100644 linux/crates/drivers/sqlite/src/lock_wait.rs create mode 100644 linux/crates/drivers/sqlite/tests/integration/lock_wait.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index df3aad295c..15c9ba3e8d 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -20,3 +20,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. - SQL Server money and smallmoney in the SQL editor and query results showed as approximate floats. - Saving a SQL Server row with a char or varchar key locked the whole table until the save ended. +- Stop could not end a SQLite statement waiting for another connection's lock. diff --git a/linux/crates/drivers/sqlite/Cargo.toml b/linux/crates/drivers/sqlite/Cargo.toml index 4d2647c6c6..3bba6c801c 100644 --- a/linux/crates/drivers/sqlite/Cargo.toml +++ b/linux/crates/drivers/sqlite/Cargo.toml @@ -13,9 +13,10 @@ path = "src/lib.rs" tablepro-core = { path = "../../core" } async-trait.workspace = true futures.workspace = true -# One direct call, sqlite3_get_autocommit, until sqlx exposes the -# transaction state of a connection. The range follows sqlx-sqlite's own, -# because only one libsqlite3-sys can be linked. +# Two direct calls sqlx does not wrap: sqlite3_get_autocommit, until sqlx +# exposes the transaction state of a connection, and sqlite3_busy_handler. +# The range follows sqlx-sqlite's own, because only one libsqlite3-sys can +# be linked. libsqlite3-sys.workspace = true sqlparser.workspace = true sqlx = { workspace = true, features = ["sqlite-unbundled"] } diff --git a/linux/crates/drivers/sqlite/src/autocommit.rs b/linux/crates/drivers/sqlite/src/autocommit.rs index 61d3c74e05..beb762b5cd 100644 --- a/linux/crates/drivers/sqlite/src/autocommit.rs +++ b/linux/crates/drivers/sqlite/src/autocommit.rs @@ -2,9 +2,8 @@ //! //! sqlx tracks only the transactions it began itself and keeps its own //! flag crate-private, so a BEGIN the user typed in the editor is -//! invisible to it. SQLite's own answer is `sqlite3_get_autocommit`. -//! This is the crate's one unsafe call, an interim until sqlx exposes -//! the state on `LockedSqliteHandle`. +//! invisible to it. SQLite's own answer is `sqlite3_get_autocommit`, +//! called directly until sqlx exposes the state on `LockedSqliteHandle`. use sqlx::sqlite::LockedSqliteHandle; diff --git a/linux/crates/drivers/sqlite/src/connection.rs b/linux/crates/drivers/sqlite/src/connection.rs index 547981d4de..9a3e25e0a1 100644 --- a/linux/crates/drivers/sqlite/src/connection.rs +++ b/linux/crates/drivers/sqlite/src/connection.rs @@ -159,9 +159,10 @@ impl SqliteConnection { { let flag = InterruptFlag::new(); let stop = Arc::new(SqliteStop::new(flag.clone())); + let lock_timeout = self.settings.lock_timeout; self.calls .run_read(options, pooled, stop, move |mut pooled, _request| async move { - let result = match flag.install(pooled.connection()).await { + let result = match flag.install(pooled.connection(), lock_timeout).await { Ok(()) => work(pooled.connection()).await, Err(error) => Err(error), }; @@ -368,6 +369,7 @@ impl Connection for SqliteConnection { let connection = self.open_standalone(&options).await?; let flag = InterruptFlag::new(); let stop = Arc::new(SqliteStop::new(flag.clone())); + let lock_timeout = self.settings.lock_timeout; let read = read.clone(); let streamed = self .calls @@ -377,7 +379,7 @@ impl Connection for SqliteConnection { stop, move |mut held, _request| async move { let mut sink = sink; - let result = match flag.install(held.connection()).await { + let result = match flag.install(held.connection(), lock_timeout).await { Ok(()) => match &read { ReadStatement::Generated(statement) => { run_bound(held.connection(), statement, sink.as_mut()).await @@ -416,11 +418,12 @@ impl Connection for SqliteConnection { let ledger = Arc::new(WriteLedger::new(WriteAtomicity::Transactional)); let flag = InterruptFlag::new(); let stop = Arc::new(SqliteStop::new(flag.clone())); + let lock_timeout = self.settings.lock_timeout; let batch = batch.clone(); let recorded = ledger.clone(); self.calls .run_write(options, pooled, stop, ledger, move |mut pooled, request| async move { - let result = write_batch::run(&mut pooled, &batch, &recorded, &flag, &request).await; + let result = write_batch::run(&mut pooled, &batch, &recorded, &flag, &request, lock_timeout).await; (pooled, result) }) .await @@ -447,6 +450,7 @@ impl Connection for SqliteConnection { &self.path, options, &self.liveness, + self.settings.lock_timeout, cancel, ) .await?; diff --git a/linux/crates/drivers/sqlite/src/editor_session.rs b/linux/crates/drivers/sqlite/src/editor_session.rs index f913d6fe40..f68b9423f3 100644 --- a/linux/crates/drivers/sqlite/src/editor_session.rs +++ b/linux/crates/drivers/sqlite/src/editor_session.rs @@ -15,7 +15,7 @@ use tokio_util::sync::CancellationToken; use crate::autocommit::is_autocommit; use crate::deadline; -use crate::errors::{is_interrupt, map_error, map_open_error}; +use crate::errors::{map_error, map_open_error}; use crate::interrupt::InterruptFlag; use crate::result_stream::{StreamOutcome, run_script}; @@ -33,6 +33,7 @@ pub(crate) struct SqliteEditorSession { /// The running statement's stop, so closing the tab stops it. current: Mutex>, statement_timeout: Option, + lock_timeout: Duration, cancel_grace: Duration, close_timeout: Duration, } @@ -50,6 +51,7 @@ impl SqliteEditorSession { path: &Path, session: EditorSessionOptions, liveness: &LivenessPolicy, + lock_timeout: Duration, cancel: CancellationToken, ) -> Result { let connecting = SqlxConnection::connect_with(options); @@ -73,6 +75,7 @@ impl SqliteEditorSession { lost: AtomicBool::new(false), current: Mutex::new(None), statement_timeout: session.statement_timeout, + lock_timeout, cancel_grace: liveness.cancel_grace, close_timeout: liveness.close_timeout, }) @@ -108,8 +111,8 @@ impl SqliteEditorSession { let settled = tokio::time::timeout(self.cancel_grace, &mut running).await; Ok(match (stop, settled) { (_, Ok(Ok(outcome))) => completed(outcome), - (Stop::Cancelled, Ok(Err(error))) if is_interrupt(&error) => RunEnd::Cancelled, - (Stop::DeadlineElapsed, Ok(Err(error))) if is_interrupt(&error) => { + (Stop::Cancelled, Ok(Err(error))) if flag.stopped(&error) => RunEnd::Cancelled, + (Stop::DeadlineElapsed, Ok(Err(error))) if flag.stopped(&error) => { RunEnd::TimedOut { server_cancelled: true } } (_, Ok(Err(error))) => return session_failure(error), @@ -194,7 +197,7 @@ impl EditorSession for SqliteEditorSession { let flag = InterruptFlag::new(); self.set_current(Some(flag.clone())); - let ended = match flag.install(connection).await { + let ended = match flag.install(connection, self.lock_timeout).await { Ok(()) => self.supervise(connection, &flag, &sql, &options, sink.as_mut()).await, Err(error) => session_failure(error), }; diff --git a/linux/crates/drivers/sqlite/src/interrupt.rs b/linux/crates/drivers/sqlite/src/interrupt.rs index 21724b8c22..6f654fdd5a 100644 --- a/linux/crates/drivers/sqlite/src/interrupt.rs +++ b/linux/crates/drivers/sqlite/src/interrupt.rs @@ -5,16 +5,20 @@ //! through it would arrive only once the statement it was meant to stop //! had ended. The handler runs on the worker thread itself, between //! virtual machine instructions, and stops the statement when the call -//! has raised its flag. +//! has raised its flag. A statement still waiting for another +//! connection's lock runs no instructions, so the lock wait checks the +//! same flag (`lock_wait`). use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; use async_trait::async_trait; use sqlx::sqlite::SqliteConnection as SqlxConnection; use tablepro_core::{DriverError, EngineStop, StopDelivery, StopScope}; use crate::errors::{is_interrupt, map_error}; +use crate::lock_wait::LockWait; /// How many virtual machine instructions run between two looks at the /// flag: often enough that a stop lands within milliseconds, rarely @@ -41,11 +45,31 @@ impl InterruptFlag { self.0.store(false, Ordering::SeqCst); } - /// Make this flag the one the connection's statements answer to. - pub(crate) async fn install(&self, connection: &mut SqlxConnection) -> Result<(), DriverError> { - let flag = self.0.clone(); + pub(crate) fn is_raised(&self) -> bool { + self.0.load(Ordering::SeqCst) + } + + /// Whether `error` is SQLite giving way to this flag: a running + /// statement is interrupted, and one waiting for a lock gives up + /// as busy. + pub(crate) fn stopped(&self, error: &DriverError) -> bool { + is_interrupt(error) || (self.is_raised() && *error == DriverError::Busy) + } + + /// Make this flag the one the connection's statements answer to, + /// with `lock_timeout` as the longest wait for another connection's + /// lock. + pub(crate) async fn install( + &self, + connection: &mut SqlxConnection, + lock_timeout: Duration, + ) -> Result<(), DriverError> { + let wait = Arc::new(LockWait::new(self.0.clone(), lock_timeout)); let mut handle = connection.lock_handle().await.map_err(map_error)?; - handle.set_progress_handler(INSTRUCTIONS_PER_CHECK, move || !flag.load(Ordering::Relaxed)); + wait.install(&mut handle)?; + // The progress handler owns the wait, so it lives exactly as long + // as this call's statements can ask for it. + handle.set_progress_handler(INSTRUCTIONS_PER_CHECK, move || !wait.stopped()); Ok(()) } } @@ -75,6 +99,6 @@ impl EngineStop for SqliteStop { } fn acknowledged(&self, error: &DriverError) -> bool { - is_interrupt(error) || matches!(error, DriverError::RolledBack { source, .. } if is_interrupt(source)) + self.flag.stopped(error) || matches!(error, DriverError::RolledBack { source, .. } if self.flag.stopped(source)) } } diff --git a/linux/crates/drivers/sqlite/src/lib.rs b/linux/crates/drivers/sqlite/src/lib.rs index f78ce9a67b..a4cba99071 100644 --- a/linux/crates/drivers/sqlite/src/lib.rs +++ b/linux/crates/drivers/sqlite/src/lib.rs @@ -16,6 +16,7 @@ mod errors; mod foreign_key_verification; mod index_sql; mod interrupt; +mod lock_wait; mod metadata; mod open; mod result_stream; diff --git a/linux/crates/drivers/sqlite/src/lock_wait.rs b/linux/crates/drivers/sqlite/src/lock_wait.rs new file mode 100644 index 0000000000..7941758f61 --- /dev/null +++ b/linux/crates/drivers/sqlite/src/lock_wait.rs @@ -0,0 +1,146 @@ +//! Waiting for another connection's lock in a way a stop can end. +//! +//! SQLite's own busy timeout sleeps inside the call that wants the +//! lock, where the progress handler never runs, so a stop could not +//! reach a waiting statement before the lock timeout had passed. This +//! busy handler sleeps the same steps and looks at the call's flag +//! between them. +//! +//! The handler is a C callback, and its argument is the connection's own +//! handle used only as a key. What it reads is found through a weak +//! reference, so it never follows a pointer the wait may have outlived. + +use std::ffi::{c_int, c_void}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError, Weak}; +use std::time::Duration; + +use sqlx::sqlite::LockedSqliteHandle; +use tablepro_core::DriverError; + +/// The sleeps between tries, in milliseconds, as SQLite's own handler +/// takes them; past the end, the last one repeats. +const DELAYS_MS: [u64; 12] = [1, 2, 5, 10, 15, 20, 25, 25, 25, 50, 50, 100]; + +/// Each connection's installed wait, by the address of its handle. +static WAITS: Mutex)>> = Mutex::new(Vec::new()); + +/// One call's lock wait: as long as the lock timeout, and over as soon +/// as the call's flag is raised. +#[derive(Debug)] +pub(crate) struct LockWait { + stop: Arc, + timeout: Duration, +} + +impl LockWait { + pub(crate) fn new(stop: Arc, timeout: Duration) -> Self { + Self { stop, timeout } + } + + pub(crate) fn stopped(&self) -> bool { + self.stop.load(Ordering::Relaxed) + } + + /// Make this the wait the connection's lock attempts answer to. The + /// caller keeps it alive for as long as it is installed; once it is + /// dropped, a lock attempt gives up at once. + pub(crate) fn install(self: &Arc, handle: &mut LockedSqliteHandle<'_>) -> Result<(), DriverError> { + let raw = handle.as_raw_handle(); + let key = raw.as_ptr().addr(); + { + let mut waits = WAITS.lock().unwrap_or_else(PoisonError::into_inner); + waits.retain(|(other, wait)| *other != key && wait.strong_count() > 0); + waits.push((key, Arc::downgrade(self))); + } + // SAFETY: `raw` comes from the locked handle, and the lock guard + // is held for the whole call, so the connection is open and no + // statement runs on it meanwhile. The argument handed to the + // callback is never dereferenced: `on_busy` uses it as a key. + let code = unsafe { libsqlite3_sys::sqlite3_busy_handler(raw.as_ptr(), Some(on_busy), raw.as_ptr().cast()) }; + match code { + libsqlite3_sys::SQLITE_OK => Ok(()), + code => Err(DriverError::Protocol(format!( + "SQLite refused the lock wait with code {code}" + ))), + } + } + + /// Sleep before the next try for the lock, or say to stop trying. + fn keep_waiting(&self, attempts: c_int) -> bool { + let Some(delay) = next_delay(attempts, self.timeout) else { + return false; + }; + if self.stopped() { + return false; + } + std::thread::sleep(delay); + !self.stopped() + } +} + +/// How long to sleep after `attempts` failed tries, or nothing once the +/// sleeps so far have used the timeout up. +fn next_delay(attempts: c_int, timeout: Duration) -> Option { + let attempts = usize::try_from(attempts).ok()?; + let ramp = DELAYS_MS.len().saturating_sub(1); + let last = DELAYS_MS.last().copied().unwrap_or(0); + let delay = DELAYS_MS.get(attempts).copied().unwrap_or(last); + let repeats = u64::try_from(attempts.saturating_sub(ramp)).unwrap_or(u64::MAX); + let prior = DELAYS_MS + .iter() + .take(attempts.min(ramp)) + .sum::() + .saturating_add(last.saturating_mul(repeats)); + let timeout = u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX); + let delay = delay.min(timeout.saturating_sub(prior)); + (delay > 0).then(|| Duration::from_millis(delay)) +} + +/// SQLite's busy handler: nonzero to try for the lock again. +extern "C" fn on_busy(connection: *mut c_void, attempts: c_int) -> c_int { + let key = connection.addr(); + let wait = WAITS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .iter() + .find(|(other, _)| *other == key) + .and_then(|(_, wait)| wait.upgrade()); + match wait { + Some(wait) => c_int::from(wait.keep_waiting(attempts)), + None => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn millis(value: u64) -> Option { + Some(Duration::from_millis(value)) + } + + #[test] + fn the_sleeps_follow_sqlites_own_until_the_timeout() { + let timeout = Duration::from_millis(400); + + assert_eq!(next_delay(0, timeout), millis(1)); + assert_eq!(next_delay(5, timeout), millis(20)); + assert_eq!(next_delay(11, timeout), millis(100)); + // The first twelve sleeps took 328 ms, so 72 are left. + assert_eq!(next_delay(12, timeout), millis(72)); + assert_eq!(next_delay(13, timeout), None); + assert_eq!(next_delay(0, Duration::ZERO), None); + assert_eq!(next_delay(-1, timeout), None); + } + + #[test] + fn a_raised_flag_ends_the_wait() { + let stop = Arc::new(AtomicBool::new(false)); + let wait = LockWait::new(stop.clone(), Duration::from_secs(10)); + + assert!(wait.keep_waiting(0)); + stop.store(true, Ordering::SeqCst); + assert!(!wait.keep_waiting(1)); + } +} diff --git a/linux/crates/drivers/sqlite/src/write_batch.rs b/linux/crates/drivers/sqlite/src/write_batch.rs index e123083e91..c925f6d7c3 100644 --- a/linux/crates/drivers/sqlite/src/write_batch.rs +++ b/linux/crates/drivers/sqlite/src/write_batch.rs @@ -7,6 +7,7 @@ //! as Busy after the lock timeout, before anything has run. use std::collections::HashMap; +use std::time::Duration; use sqlx::sqlite::SqliteConnection as SqlxConnection; use sqlx::{AssertSqlSafe, Connection as _, Row}; @@ -32,8 +33,9 @@ pub(crate) async fn run( ledger: &WriteLedger, flag: &InterruptFlag, request: &StopRequest, + lock_timeout: Duration, ) -> Result { - flag.install(pooled.connection()).await?; + flag.install(pooled.connection(), lock_timeout).await?; let checks = match &batch.foreign_keys { ForeignKeyMode::Enforced => None, ForeignKeyMode::SuspendedThenVerified { checks } => Some(checks.as_slice()), @@ -240,10 +242,12 @@ impl Write<'_> { } } - /// What the caller is told when `error` is the interrupt a stop - /// raised. + /// What the caller is told when `error` is how SQLite gave way to a + /// stop: an interrupt, or a lock wait given up as busy. The flag is + /// lowered for the rollback by then, so the request says whether a + /// stop came. fn stopped(&self, error: &DriverError) -> Option { - match is_interrupt(error) { + match is_interrupt(error) || *error == DriverError::Busy { true => self.request.reason().map(stop_cause), false => None, } diff --git a/linux/crates/drivers/sqlite/tests/integration/lock_wait.rs b/linux/crates/drivers/sqlite/tests/integration/lock_wait.rs new file mode 100644 index 0000000000..caf2758980 --- /dev/null +++ b/linux/crates/drivers/sqlite/tests/integration/lock_wait.rs @@ -0,0 +1,104 @@ +//! A stop that lands while a statement waits for another connection's +//! lock, which is longer than the cancel grace. + +use std::time::Duration; + +use sqlx::sqlite::{SqliteConnectOptions, SqliteConnection}; +use sqlx::{Connection as _, Executor as _}; +use tablepro_core::{ + CallOptions, DriverError, EditorSessionOptions, EditorSessionState, RunEnd, RunOptions, Value, WriteKind, +}; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +use crate::support::{TestFile, apply, batch, query, run, run_with}; + +/// Well inside the ten-second lock timeout, and short of the second of +/// cancel grace a stop that never lands would use up. +const PROMPT: Duration = Duration::from_millis(900); + +async fn hold_write_lock(file: &TestFile) -> SqliteConnection { + let mut holder = SqliteConnection::connect_with(&SqliteConnectOptions::new().filename(&file.path)) + .await + .unwrap(); + holder.execute("BEGIN IMMEDIATE").await.unwrap(); + holder +} + +fn cancel_after(delay: Duration) -> CancellationToken { + let cancel = CancellationToken::new(); + let stopper = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(delay).await; + stopper.cancel(); + }); + cancel +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_save_waiting_for_a_lock_stops_and_keeps_its_connection() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &["CREATE TABLE t (a)", "CREATE TEMP TABLE kept (a)"], + ) + .await + .unwrap(); + let mut holder = hold_write_lock(&file).await; + + let started = Instant::now(); + let written = connection + .write_batch( + &batch(WriteKind::RowChanges, &["INSERT INTO t VALUES (1)"]), + CallOptions::new(None, cancel_after(Duration::from_millis(200))), + ) + .await; + let elapsed = started.elapsed(); + holder.execute("ROLLBACK").await.unwrap(); + holder.close().await.unwrap(); + + assert_eq!( + written, + Err(DriverError::RolledBack { + statement_index: 0, + source: Box::new(DriverError::Cancelled), + }) + ); + assert!(elapsed < PROMPT, "the stop took {elapsed:?}"); + let kept = query(connection.as_ref(), "SELECT count(*) FROM temp.kept").await; + assert_eq!(kept.rows[0][0], Value::Int(0), "the workspace connection was replaced"); + connection.close().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_editor_run_waiting_for_a_lock_stops_and_the_next_run_starts() { + let file = TestFile::new().await; + let connection = file.open().await; + apply(connection.as_ref(), &["CREATE TABLE t (a)"]).await.unwrap(); + let session = connection + .open_editor_session(EditorSessionOptions::default(), CancellationToken::new()) + .await + .unwrap(); + let mut holder = hold_write_lock(&file).await; + + let started = Instant::now(); + let (stopped, _) = run_with( + session.as_ref(), + "INSERT INTO t VALUES (1)", + RunOptions::new(cancel_after(Duration::from_millis(200))), + ) + .await; + let (next, collected) = run(session.as_ref(), "SELECT 1").await; + let elapsed = started.elapsed(); + holder.execute("ROLLBACK").await.unwrap(); + holder.close().await.unwrap(); + + assert_eq!(stopped.end, RunEnd::Cancelled); + assert_eq!(next.end, RunEnd::Completed); + assert_eq!(collected.rows(), vec![vec![Value::Int(1)]]); + assert!(elapsed < PROMPT, "the stop and the next run took {elapsed:?}"); + assert_eq!(session.state(), EditorSessionState::Ready); + session.close().await; + connection.close().await.unwrap(); +} diff --git a/linux/crates/drivers/sqlite/tests/integration/main.rs b/linux/crates/drivers/sqlite/tests/integration/main.rs index 3009721528..6cf5e195d0 100644 --- a/linux/crates/drivers/sqlite/tests/integration/main.rs +++ b/linux/crates/drivers/sqlite/tests/integration/main.rs @@ -13,6 +13,8 @@ mod decode; #[cfg(test)] mod dialect; #[cfg(test)] +mod lock_wait; +#[cfg(test)] mod metadata; #[cfg(test)] mod open_semantics; From 6fdcbc66ad426eda93d862da21a5bf5371263355 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:41:50 +0700 Subject: [PATCH 040/178] fix(linux-sqlite): keep the connection when SQLite rolled back a save --- linux/CHANGELOG.md | 1 + .../crates/drivers/sqlite/src/write_batch.rs | 27 +++++++++++- .../sqlite/tests/integration/sessions.rs | 43 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 15c9ba3e8d..99df5340f5 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -21,3 +21,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQL Server money and smallmoney in the SQL editor and query results showed as approximate floats. - Saving a SQL Server row with a char or varchar key locked the whole table until the save ended. - Stop could not end a SQLite statement waiting for another connection's lock. +- A SQLite save that failed in a way SQLite rolls back itself closed the connection and its temporary tables. diff --git a/linux/crates/drivers/sqlite/src/write_batch.rs b/linux/crates/drivers/sqlite/src/write_batch.rs index c925f6d7c3..db9ab73e03 100644 --- a/linux/crates/drivers/sqlite/src/write_batch.rs +++ b/linux/crates/drivers/sqlite/src/write_batch.rs @@ -18,6 +18,7 @@ use tablepro_core::{ WriteBatch, WriteLedger, WriteReport, }; +use crate::autocommit::is_autocommit; use crate::catalog::{FOREIGN_KEY_CHECK_SQL, MAIN_SCHEMA}; use crate::encode::bind_all; use crate::errors::{SQLITE_IOERR, is_interrupt, map_error, primary_of}; @@ -221,8 +222,13 @@ impl Write<'_> { Ok(reports_changes(sql).then_some(affected)) } - async fn rollback(&mut self, transaction: sqlx::Transaction<'_, sqlx::Sqlite>) -> SessionFate { + async fn rollback(&mut self, mut transaction: sqlx::Transaction<'_, sqlx::Sqlite>) -> SessionFate { self.flag.lower(); + if let Err(error) = reopen_if_ended(&mut transaction).await { + tracing::warn!(%error, "the transaction state could not be read; closing the connection"); + self.discard = true; + return SessionFate::Discarded; + } match transaction.rollback().await { Ok(()) => SessionFate::RolledBack, Err(error) => { @@ -254,6 +260,25 @@ impl Write<'_> { } } +/// Some errors end the transaction inside SQLite itself: an interrupted +/// write, a full disk, a conflict resolved with ROLLBACK. sqlx still +/// counts it open, so the ROLLBACK it sends would be refused and leave +/// that count wrong for every later BEGIN. An empty transaction opened +/// in its place is what that ROLLBACK then ends. +async fn reopen_if_ended(connection: &mut SqlxConnection) -> Result<(), DriverError> { + let ended = { + let mut handle = connection.lock_handle().await.map_err(map_error)?; + is_autocommit(&mut handle) + }; + if ended { + sqlx::query("BEGIN") + .execute(&mut *connection) + .await + .map_err(map_error)?; + } + Ok(()) +} + enum StepFailure { Guard { expected: u64, actual: u64 }, Error(DriverError), diff --git a/linux/crates/drivers/sqlite/tests/integration/sessions.rs b/linux/crates/drivers/sqlite/tests/integration/sessions.rs index e18a214ffb..2261884179 100644 --- a/linux/crates/drivers/sqlite/tests/integration/sessions.rs +++ b/linux/crates/drivers/sqlite/tests/integration/sessions.rs @@ -243,6 +243,49 @@ async fn write_interrupt_before_commit_rolls_back() { connection.close().await.unwrap(); } +/// ON CONFLICT ROLLBACK ends the transaction inside SQLite before the +/// batch gets to roll back itself; the workspace connection, and the +/// temporary table on it, must survive that. +#[tokio::test] +async fn a_batch_sqlite_already_rolled_back_keeps_its_connection() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE t (a INTEGER UNIQUE ON CONFLICT ROLLBACK)", + "INSERT INTO t VALUES (1)", + "CREATE TEMP TABLE kept (a)", + ], + ) + .await + .unwrap(); + + let written = connection + .write_batch( + &batch( + WriteKind::RowChanges, + &["INSERT INTO t VALUES (2)", "INSERT INTO t VALUES (1)"], + ), + call(), + ) + .await; + + assert!( + matches!(&written, Err(DriverError::RolledBack { statement_index: 1, .. })), + "{written:?}" + ); + let rows = query(connection.as_ref(), "SELECT count(*) FROM t").await; + assert_eq!(rows.rows[0][0], Value::Int(1)); + let kept = query(connection.as_ref(), "SELECT count(*) FROM temp.kept").await; + assert_eq!(kept.rows[0][0], Value::Int(0), "the workspace connection was replaced"); + let after = connection + .write_batch(&batch(WriteKind::RowChanges, &["INSERT INTO t VALUES (3)"]), call()) + .await; + assert!(after.is_ok(), "{after:?}"); + connection.close().await.unwrap(); +} + #[tokio::test] async fn a_second_run_while_one_is_going_is_refused() { let file = TestFile::new().await; From 39f7d5a6e9f4c09e209dd29864382d1c237eb7f3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:41:51 +0700 Subject: [PATCH 041/178] fix(linux-sqlite): run statements before an unclosed comment alone --- linux/CHANGELOG.md | 1 + .../drivers/sqlite/src/result_stream.rs | 53 ++++++++++++++++--- .../sqlite/tests/integration/sessions.rs | 33 ++++++++++++ 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 99df5340f5..546bfd141b 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -22,3 +22,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Saving a SQL Server row with a char or varchar key locked the whole table until the save ended. - Stop could not end a SQLite statement waiting for another connection's lock. - A SQLite save that failed in a way SQLite rolls back itself closed the connection and its temporary tables. +- A SQLite script ending in an unclosed comment showed later results under the first statement's columns. diff --git a/linux/crates/drivers/sqlite/src/result_stream.rs b/linux/crates/drivers/sqlite/src/result_stream.rs index 744519e667..68fedd0aa5 100644 --- a/linux/crates/drivers/sqlite/src/result_stream.rs +++ b/linux/crates/drivers/sqlite/src/result_stream.rs @@ -12,7 +12,9 @@ use sqlx::sqlite::{SqliteColumn, SqliteConnection as SqlxConnection}; use sqlx::{AssertSqlSafe, Column, Executor, SqlSafeStr, Statement as _, TypeInfo}; use tablepro_core::column::{ColumnType, ResultColumn}; use tablepro_core::sql_syntax::SqlGrammar; -use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptTokenKind}; +use tablepro_core::sql_syntax::script::{ + LexicalSettings, OpenConstruct, ScriptDiagnostic, ScriptPlan, ScriptTokenKind, +}; use tablepro_core::statement::{BoundParam, Statement}; use tablepro_core::value::Value; use tablepro_core::{DriverError, QueryResult, ResultEvent, ResultSink, RowLimit, SinkFlow, Truncation}; @@ -111,19 +113,43 @@ pub(crate) async fn run_bound( } /// The statements of a script, split where SQLite itself would end one -/// (a trigger body's semicolons do not end it). Text the splitter -/// cannot finish, such as an unterminated string, goes to SQLite whole -/// so the error is SQLite's own. +/// (a trigger body's semicolons do not end it). +/// +/// An unterminated string, identifier or comment runs to the end of the +/// text. The statements before it still run one at a time, and the one +/// it opens goes to SQLite as it stands, so the error is SQLite's own. +/// Sent whole, the script would run as one statement under the first +/// statement's columns. SQLite reads an open comment as a comment, so +/// only the text before one is sent. pub(crate) fn split_statements(sql: &str) -> Vec<&str> { let plan = ScriptPlan::build( sql, SqlGrammar::Sqlite, LexicalSettings::default_for(SqlGrammar::Sqlite), ); - if !plan.diagnostics().is_empty() { - return vec![sql]; + let open = plan + .diagnostics() + .iter() + .map(|ScriptDiagnostic::Unterminated { construct, start }| (*start, *construct)) + .min_by_key(|(start, _)| *start); + let (open_at, tail_end) = match open { + Some((start, OpenConstruct::BlockComment)) => (start, start), + Some((start, _)) => (start, sql.len()), + None => (sql.len(), sql.len()), + }; + let mut statements = Vec::with_capacity(plan.statements().len()); + for statement in plan.statements() { + if statement.range.end <= open_at { + statements.push(statement.text(sql)); + continue; + } + let tail = sql.get(statement.range.start..tail_end).unwrap_or_default().trim_end(); + if !tail.is_empty() { + statements.push(tail); + } + break; } - plan.statements().iter().map(|statement| statement.text(sql)).collect() + statements } /// Whether SQLite's change count describes this statement. It counts @@ -300,6 +326,19 @@ mod tests { assert!(split_statements(" -- only a comment\n").is_empty()); } + #[test] + fn statements_before_an_unfinished_one_still_run_alone() { + assert_eq!( + split_statements("SELECT 1 AS a; SELECT 2 AS b, 3 AS c /* open"), + ["SELECT 1 AS a", "SELECT 2 AS b, 3 AS c"] + ); + assert_eq!( + split_statements("SELECT 1; SELECT 'open; still open"), + ["SELECT 1", "SELECT 'open; still open"] + ); + assert_eq!(split_statements("SELECT 1; /* open"), ["SELECT 1"]); + } + #[test] fn only_row_changes_report_a_change_count() { assert!(reports_changes(" INSERT INTO t VALUES (1)")); diff --git a/linux/crates/drivers/sqlite/tests/integration/sessions.rs b/linux/crates/drivers/sqlite/tests/integration/sessions.rs index 2261884179..41d5605ec9 100644 --- a/linux/crates/drivers/sqlite/tests/integration/sessions.rs +++ b/linux/crates/drivers/sqlite/tests/integration/sessions.rs @@ -57,6 +57,39 @@ async fn select_star_after_add_column_reads_the_new_column() { connection.close().await.unwrap(); } +/// A comment left open at the end makes the script unfinished, which +/// must not merge its statements into one run under the first header. +#[tokio::test] +async fn an_unterminated_comment_keeps_each_statement_its_own_columns() { + let file = TestFile::new().await; + let connection = file.open().await; + let session = connection + .open_editor_session(EditorSessionOptions::default(), CancellationToken::new()) + .await + .unwrap(); + + let (summary, collected) = run(session.as_ref(), "SELECT 1 AS a; SELECT 2 AS b, 3 AS c /* open").await; + + assert_eq!(summary.end, RunEnd::Completed); + let headers: Vec> = collected + .events() + .into_iter() + .filter_map(|event| match event { + ResultEvent::ResultSetStarted { columns } => { + Some(columns.iter().map(|column| column.name.clone()).collect()) + } + _ => None, + }) + .collect(); + assert_eq!(headers, [vec!["a".to_owned()], vec!["b".to_owned(), "c".to_owned()]]); + assert_eq!( + collected.rows(), + vec![vec![Value::Int(1)], vec![Value::Int(2), Value::Int(3)]] + ); + session.close().await; + connection.close().await.unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn recursive_cte_cancelled() { let file = TestFile::new().await; From cae14edbb75809bd295c3d4ace3b78e961aa2477 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:41:52 +0700 Subject: [PATCH 042/178] fix(linux-sqlite): write a NUL in a text literal through char(0) --- linux/CHANGELOG.md | 1 + linux/crates/drivers/sqlite/src/dialect.rs | 30 ++++++++++++++++++- .../sqlite/tests/integration/dialect.rs | 21 +++++++++++-- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 546bfd141b..797f5cfc25 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -23,3 +23,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Stop could not end a SQLite statement waiting for another connection's lock. - A SQLite save that failed in a way SQLite rolls back itself closed the connection and its temporary tables. - A SQLite script ending in an unclosed comment showed later results under the first statement's columns. +- SQLite text containing a NUL character exported as SQL that SQLite cannot read. diff --git a/linux/crates/drivers/sqlite/src/dialect.rs b/linux/crates/drivers/sqlite/src/dialect.rs index 3d55d4e896..dfaf3d3005 100644 --- a/linux/crates/drivers/sqlite/src/dialect.rs +++ b/linux/crates/drivers/sqlite/src/dialect.rs @@ -119,7 +119,7 @@ impl SqlDialect for SqliteDialect { // Text and the temporal values, stored as the ISO text // SQLite's date functions read. other => match value_to_text(other) { - Some(text) => Ok(format!("'{}'", text.replace('\'', "''"))), + Some(text) => Ok(text_literal(&text)), None => Ok("NULL".to_owned()), }, } @@ -202,6 +202,20 @@ impl SqlDialect for SqliteDialect { } } +/// SQLite ends SQL text at the first NUL, wherever it stands, so a NUL +/// inside quotes would cut the literal short. Each one is joined in +/// with `char(0)` instead. +fn text_literal(text: &str) -> String { + let quoted = |piece: &str| format!("'{}'", piece.replace('\'', "''")); + match text.contains('\0') { + false => quoted(text), + true => { + let pieces: Vec = text.split('\0').map(quoted).collect(); + format!("({})", pieces.join(" || char(0) || ")) + } + } +} + /// The parameter a value takes for a column. /// /// A column of an ordinary table stores whatever class it is given, so @@ -391,6 +405,20 @@ mod tests { assert!(dialect().literal(&Value::Float64(f64::NAN), &text).is_err()); } + #[test] + fn a_nul_in_text_is_joined_in_rather_than_quoted() { + let text = ColumnType::untyped_text("TEXT"); + + assert_eq!( + dialect().literal(&Value::Text("a\0b'c".to_owned()), &text).as_deref(), + Ok("('a' || char(0) || 'b''c')") + ); + assert_eq!( + dialect().literal(&Value::Text("\0".to_owned()), &text).as_deref(), + Ok("('' || char(0) || '')") + ); + } + #[test] fn literal_round_trips_through_the_sqlite_parser() { use sqlparser::dialect::SQLiteDialect; diff --git a/linux/crates/drivers/sqlite/tests/integration/dialect.rs b/linux/crates/drivers/sqlite/tests/integration/dialect.rs index 03a3efed93..c55ab8a7f2 100644 --- a/linux/crates/drivers/sqlite/tests/integration/dialect.rs +++ b/linux/crates/drivers/sqlite/tests/integration/dialect.rs @@ -1,11 +1,12 @@ //! The dialect's filters against SQLite itself. -use tablepro_core::Connection; use tablepro_core::browse::build_count; +use tablepro_core::column::ColumnType; use tablepro_core::filter::{FilterOp, FilterRule, FilterSet, FilterValue}; use tablepro_core::meta::TableRef; +use tablepro_core::{Connection, Value}; -use crate::support::{TestFile, apply, call}; +use crate::support::{TestFile, apply, call, query}; async fn matching(connection: &dyn Connection, table: &TableRef, op: FilterOp, text: &str) -> u64 { let columns = connection.fetch_columns(table, call()).await.unwrap(); @@ -70,3 +71,19 @@ async fn insensitive_filter_matches_mixed_case() { assert_eq!(matching(connection.as_ref(), &table, FilterOp::Ilike, "%aLP%").await, 1); connection.close().await.unwrap(); } + +#[tokio::test] +async fn a_text_literal_with_a_nul_reads_back_whole() { + let file = TestFile::new().await; + let connection = file.open().await; + let text = "before\0after 'quoted'"; + let literal = connection + .dialect() + .literal(&Value::Text(text.to_owned()), &ColumnType::untyped_text("TEXT")) + .unwrap(); + + let read = query(connection.as_ref(), &format!("SELECT {literal}")).await; + + assert_eq!(read.rows[0][0], Value::Text(text.to_owned())); + connection.close().await.unwrap(); +} From e2b49fc02fe2ac61c0a726cff332a673df238e63 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:49:22 +0700 Subject: [PATCH 043/178] fix(linux-storage): keep an import from taking over saved secrets --- linux/CHANGELOG.md | 4 ++ linux/crates/storage/src/connections.rs | 92 +++++++++++++++++++++---- 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 5aa8e0614c..4b9529ec3d 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -18,3 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. + +### Security + +- An imported connection with the id of a saved one could take over its stored passwords. diff --git a/linux/crates/storage/src/connections.rs b/linux/crates/storage/src/connections.rs index 916eb719bf..32482f8475 100644 --- a/linux/crates/storage/src/connections.rs +++ b/linux/crates/storage/src/connections.rs @@ -80,6 +80,20 @@ impl SavedConnection { } } + /// Whether the two differ at most in how the list shows them. A + /// field added later counts as part of the connection until it is + /// listed here. + fn connects_like(&self, other: &Self) -> bool { + let shown_alike = |saved: &Self| Self { + name: String::new(), + last_opened_at: None, + color: None, + group: None, + ..saved.clone() + }; + shown_alike(self) == shown_alike(other) + } + fn database_target(&self) -> String { let mut target = String::new(); if !self.username.is_empty() { @@ -177,16 +191,17 @@ pub struct ConnectionListSnapshot { /// What an import did to the list. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct ImportReport { - /// Connections the list did not have. + /// Connections the list did not have, including one whose id was + /// taken by a different connection and was given its own. pub added: usize, - /// Connections already in the list under the same id, overwritten - /// by the file's version. - pub replaced: usize, + /// Connections already in the list that the file only renamed, + /// recoloured or regrouped. + pub updated: usize, } impl ImportReport { pub fn total(self) -> usize { - self.added + self.replaced + self.added + self.updated } } @@ -308,6 +323,12 @@ impl ConnectionStore { /// rather than doubling them, and a file from another machine adds /// its own. `path` names the file in any problem reported, since /// the user picked it and needs to know which one was refused. + /// + /// The keyring keeps secrets by connection id, so an entry may only + /// be updated by one that sends them to the same place in the same + /// way. Anything else under a taken id is added under a new id and + /// starts with no secrets, so a crafted file cannot point a saved + /// connection's password at another host. pub fn import_blocking(&self, path: &Path, bytes: &[u8]) -> Result { let incoming = crate::document::decode_document::(path, bytes) .map_err(StorageError::DocumentUnavailable)? @@ -315,11 +336,18 @@ impl ConnectionStore { let mut state = self.lock(); let mut list = self.ensure_loaded(&mut state)?.to_vec(); let mut report = ImportReport::default(); - for connection in incoming { + for mut connection in incoming { match list.iter_mut().find(|saved| saved.id == connection.id) { - Some(existing) => { - *existing = connection; - report.replaced += 1; + Some(existing) if existing.connects_like(&connection) => { + existing.name = connection.name; + existing.color = connection.color; + existing.group = connection.group; + report.updated += 1; + } + Some(_) => { + connection.id = Uuid::new_v4(); + list.push(connection); + report.added += 1; } None => { list.push(connection); @@ -1029,7 +1057,7 @@ mod tests { .import_blocking(Path::new("/tmp/connections.json"), &bytes) .expect("import"); - assert_eq!(report, ImportReport { added: 1, replaced: 0 }); + assert_eq!(report, ImportReport { added: 1, updated: 0 }); let list = target.load_blocking().expect("load"); assert_eq!(list.len(), 1); assert_eq!(list[0].name, "production"); @@ -1050,13 +1078,51 @@ mod tests { let first = store.import_blocking(Path::new("/tmp/c.json"), &bytes).expect("import"); let second = store.import_blocking(Path::new("/tmp/c.json"), &bytes).expect("import"); - assert_eq!(first, ImportReport { added: 0, replaced: 1 }); - assert_eq!(second, ImportReport { added: 0, replaced: 1 }); + assert_eq!(first, ImportReport { added: 0, updated: 1 }); + assert_eq!(second, ImportReport { added: 0, updated: 1 }); let list = store.load_blocking().expect("load"); assert_eq!(list.len(), 1); assert_eq!(list[0].name, "production (renamed)"); } + #[test] + fn an_import_never_points_a_saved_entry_or_its_secrets_somewhere_else() { + let mut elsewhere = saved("production"); + elsewhere.host = "db.attacker.example".to_owned(); + let mut in_the_clear = saved("production"); + in_the_clear.tls = TlsConfig::disabled(); + for (variant, crafted) in [("another host", elsewhere), ("TLS off", in_the_clear)] { + let root = TempDir::new().expect("tempdir"); + let store = store(&root); + let mine = SavedConnection { + id: crafted.id, + ..saved("production") + }; + store.upsert_blocking(mine.clone()).expect("upsert"); + let bytes = crate::document::encode_document(&ConnectionsDocument { + connections: vec![crafted.clone()], + }) + .expect("encode"); + + let report = store.import_blocking(Path::new("/tmp/c.json"), &bytes).expect("import"); + + assert_eq!(report, ImportReport { added: 1, updated: 0 }, "{variant}"); + let list = store.load_blocking().expect("load"); + assert_eq!(list.len(), 2, "{variant}"); + let kept = list.iter().find(|saved| saved.id == mine.id).expect("the saved entry"); + assert_eq!(*kept, mine, "{variant}: the import changed the saved entry"); + let imported = list.iter().find(|saved| saved.id != mine.id).expect("the import"); + assert_eq!( + SavedConnection { + id: crafted.id, + ..imported.clone() + }, + crafted, + "{variant}" + ); + } + } + #[test] fn an_import_leaves_the_connections_the_file_does_not_mention() { let root = TempDir::new().expect("tempdir"); @@ -1073,7 +1139,7 @@ mod tests { let report = store.import_blocking(Path::new("/tmp/c.json"), &bytes).expect("import"); - assert_eq!(report, ImportReport { added: 1, replaced: 0 }); + assert_eq!(report, ImportReport { added: 1, updated: 0 }); let names: Vec = store .load_blocking() .expect("load") From 3708ed17fd73491b699e6706a055d396ac8ea596 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:49:26 +0700 Subject: [PATCH 044/178] fix(linux-ssh): leave every new SSH host key to the app's dialog --- linux/CHANGELOG.md | 2 +- .../crates/session/tests/session_postgres.rs | 22 +++++++-- linux/crates/ssh/src/argv.rs | 33 ++++++++++++- linux/crates/ssh/tests/openssh_session.rs | 48 ++++++++++++++++--- 4 files changed, 91 insertions(+), 14 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 4b9529ec3d..b5df25857e 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Password prompt on connect when the keyring holds no password, with a choice to remember it. -- SSH tunnels through the system OpenSSH client, using your SSH agent, host aliases and jump hosts. +- SSH tunnels through the system OpenSSH client, with your agent, host aliases and jump hosts, confirming new host keys. - Security group in the connect dialog: encryption mode, CA certificate, client certificate and TLS server name. - SQLite file chooser in the connect dialog, with New Database to create a file. - Results list in the SQL editor with one entry per statement, its rows affected or error, and the open transaction. diff --git a/linux/crates/session/tests/session_postgres.rs b/linux/crates/session/tests/session_postgres.rs index 8b956bbdc7..bf7adfb2c2 100644 --- a/linux/crates/session/tests/session_postgres.rs +++ b/linux/crates/session/tests/session_postgres.rs @@ -58,20 +58,31 @@ fn password(credentials: &FixtureCredentials) -> Credentials { } /// SSH services that run the system client with a private config: no -/// agent, a throwaway known_hosts, and new host keys accepted, so no -/// prompt is ever raised. +/// agent and a throwaway known_hosts. The app asks about every host key +/// it does not know, so a test trusts its server's key first. struct IsolatedSsh { - _dir: tempfile::TempDir, + dir: tempfile::TempDir, services: SshServices, } +impl IsolatedSsh { + async fn trust(&self, sshd: &OpenSshFixture) { + let key = sshd.host_public_key().await.expect("the server's host key"); + std::fs::write( + self.dir.path().join("known_hosts"), + format!("[{}]:{} {key}\n", sshd.host(), sshd.port()), + ) + .expect("the known_hosts file"); + } +} + fn isolated_ssh() -> IsolatedSsh { let dir = tempfile::tempdir().expect("a temporary directory"); let config = dir.path().join("ssh_config"); std::fs::write( &config, format!( - "Host *\n UserKnownHostsFile {}\n GlobalKnownHostsFile /dev/null\n StrictHostKeyChecking accept-new\n IdentityAgent none\n IdentitiesOnly yes\n", + "Host *\n UserKnownHostsFile {}\n GlobalKnownHostsFile /dev/null\n IdentityAgent none\n IdentitiesOnly yes\n", dir.path().join("known_hosts").display() ), ) @@ -91,7 +102,7 @@ fn isolated_ssh() -> IsolatedSsh { ssh_keygen_program: PathBuf::from("ssh-keygen"), askpass_program: PathBuf::from("/nonexistent/tablepro-askpass"), }; - IsolatedSsh { _dir: dir, services } + IsolatedSsh { dir, services } } fn connector(ssh: SshServices) -> Arc { @@ -305,6 +316,7 @@ async fn session_through_openssh_reconnects_after_the_master_dies() { .await .expect("an OpenSSH server"); let ssh = isolated_ssh(); + ssh.trust(&sshd).await; let connector = connector(ssh.services.clone()); let pending = establish(&connector, request(ssh_target(&sshd, &postgres, &private_key))).await; diff --git a/linux/crates/ssh/src/argv.rs b/linux/crates/ssh/src/argv.rs index 478301d4dd..6cf69ef331 100644 --- a/linux/crates/ssh/src/argv.rs +++ b/linux/crates/ssh/src/argv.rs @@ -6,12 +6,14 @@ use tablepro_core::{LivenessPolicy, NetworkEndpoint}; use crate::{SshAuth, SshConfig}; -const FORCED_OPTIONS: [&str; 14] = [ +const FORCED_OPTIONS: [&str; 15] = [ "ControlMaster=yes", "ControlPersist=no", "ForkAfterAuthentication=no", "StreamLocalBindMask=0177", "BatchMode=no", + // The app's host-key dialog is the trust decision, so a user config's accept-new or no must not skip it. + "StrictHostKeyChecking=ask", "VisualHostKey=no", "Tunnel=no", "ClearAllForwardings=yes", @@ -183,6 +185,8 @@ mod tests { "-o", "BatchMode=no", "-o", + "StrictHostKeyChecking=ask", + "-o", "VisualHostKey=no", "-o", "Tunnel=no", @@ -212,6 +216,33 @@ mod tests { ); } + #[test] + fn every_auth_mode_leaves_the_host_key_question_to_the_app() { + let auths = [ + SshAuth::Agent, + SshAuth::PrivateKey { + path: None, + passphrase: None, + }, + SshAuth::Password { + password: SecretString::from("secret"), + }, + SshAuth::KeyboardInteractive, + ]; + for auth in auths { + let args = master_args( + &config(None, None, auth), + Path::new("/tmp/control"), + &LivenessPolicy::DESKTOP, + ); + let strict: Vec<&str> = strings(&args) + .into_iter() + .filter(|arg| arg.starts_with("StrictHostKeyChecking=")) + .collect(); + assert_eq!(strict, ["StrictHostKeyChecking=ask"]); + } + } + #[test] fn port_and_user_only_when_set() { let control = Path::new("/tmp/control"); diff --git a/linux/crates/ssh/tests/openssh_session.rs b/linux/crates/ssh/tests/openssh_session.rs index 35516a64f7..3ff4a8932e 100644 --- a/linux/crates/ssh/tests/openssh_session.rs +++ b/linux/crates/ssh/tests/openssh_session.rs @@ -29,10 +29,22 @@ struct Fixture { _temp: tempfile::TempDir, host: String, port: u16, + config: PathBuf, known_hosts: PathBuf, services: SshServices, } +/// The user's ssh config, with the host-key policy they chose. +fn write_config(config: &Path, known_hosts: &Path, strict_host_key_checking: &str) -> std::io::Result<()> { + std::fs::write( + config, + format!( + "Host *\n UserKnownHostsFile {}\n GlobalKnownHostsFile /dev/null\n StrictHostKeyChecking {strict_host_key_checking}\n IdentityAgent none\n IdentitiesOnly yes\n PubkeyAuthentication no\n", + known_hosts.display() + ), + ) +} + async fn start() -> TestResult { let container = GenericImage::new("alpine", "3.22") .with_exposed_port(2222.tcp()) @@ -46,13 +58,7 @@ async fn start() -> TestResult { let temp = tempfile::tempdir()?; let known_hosts = temp.path().join("known_hosts"); let config = temp.path().join("ssh_config"); - std::fs::write( - &config, - format!( - "Host *\n UserKnownHostsFile {}\n GlobalKnownHostsFile /dev/null\n StrictHostKeyChecking ask\n IdentityAgent none\n IdentitiesOnly yes\n PubkeyAuthentication no\n", - known_hosts.display() - ), - )?; + write_config(&config, &known_hosts, "ask")?; let wrapper = temp.path().join("ssh"); std::fs::write( &wrapper, @@ -73,6 +79,7 @@ async fn start() -> TestResult { _temp: temp, host, port, + config, known_hosts, services, }) @@ -210,6 +217,33 @@ async fn unknown_host_declined_is_host_key_unknown_declined() { )); } +#[tokio::test] +#[ignore = "requires docker"] +async fn a_user_config_that_skips_the_host_key_check_still_asks() { + let fixture = start().await.unwrap(); + for strict in ["no", "accept-new"] { + write_config(&fixture.config, &fixture.known_hosts, strict).unwrap(); + if let Err(error) = std::fs::remove_file(&fixture.known_hosts) { + assert_eq!(error.kind(), std::io::ErrorKind::NotFound, "{error}"); + } + + let (session, prompter) = connect(&fixture, password_auth(), vec![PromptReply::Cancelled]).await; + + assert!( + matches!( + session, + Err(TransportError::Ssh(SshFailure::HostKeyUnknown { declined: true, .. })) + ), + "StrictHostKeyChecking {strict} trusted the key without asking" + ); + let purposes: Vec = prompter.requests().into_iter().map(|request| request.purpose).collect(); + assert!( + matches!(purposes.as_slice(), [PromptPurpose::SshHostKeyConfirmation { .. }]), + "{strict}: {purposes:?}" + ); + } +} + #[tokio::test] #[ignore = "requires docker"] async fn wrong_password_is_authentication_rejected() { From baef3b164dc3918ac70fead364ddbfd707f833a2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:49:34 +0700 Subject: [PATCH 045/178] fix(linux): stop a cancelled connect from saving or prompting --- linux/CHANGELOG.md | 1 + linux/crates/app/src/ui/app/connect_flow.rs | 296 ++++++++++++++++++ linux/crates/app/src/ui/app/connection.rs | 46 +-- linux/crates/app/src/ui/app/mod.rs | 1 + .../app/src/ui/credential_prompt/presenter.rs | 64 +++- .../src/ui/credential_prompt/prompt_form.rs | 9 + 6 files changed, 380 insertions(+), 37 deletions(-) create mode 100644 linux/crates/app/src/ui/app/connect_flow.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index b5df25857e..09c738f03d 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. +- A cancelled connect could still save the connection and its password, and left its password dialog open. ### Security diff --git a/linux/crates/app/src/ui/app/connect_flow.rs b/linux/crates/app/src/ui/app/connect_flow.rs new file mode 100644 index 0000000000..cf974f9a3a --- /dev/null +++ b/linux/crates/app/src/ui/app/connect_flow.rs @@ -0,0 +1,296 @@ +//! The work behind a connect the window starts, run off the GTK thread. +//! +//! Every step stops at the attempt's cancel: a cancelled connect asks +//! for nothing more, keeps no secret and saves no entry. + +use tablepro_core::DriverRegistry; +use tablepro_core::credentials::{CredentialInteraction, SecretError, SecretKind, SecretVault}; +use tablepro_session::connect::ConnectionWrites; +use tablepro_session::{ + ConnectError, ConnectRequest, Connector, CredentialSource, PendingSave, PendingSession, PersistReport, +}; +use tablepro_storage::{CredentialDemand, KeyringCredentials, SavedConnection, StorageError}; +use tokio_util::sync::CancellationToken; + +/// Why a submitted connection did not open. +#[derive(Debug)] +pub(super) enum SubmitError { + Connect(ConnectError), + Save(StorageError), +} + +/// Connect to what the dialog was filled in with, then save it. The +/// entry is only written once the connect works, so a typo never lands +/// in the connection list. +pub(super) async fn connect_then_save( + connector: &dyn Connector, + request: ConnectRequest, + interaction: CredentialInteraction, + cancel: CancellationToken, + secrets: &dyn SecretVault, + writes: &dyn ConnectionWrites, + save: PendingSave, +) -> Result<(PendingSession, PersistReport), SubmitError> { + let pending = connector + .establish(request, interaction, cancel.clone()) + .await + .map_err(SubmitError::Connect)?; + let pending = unless_cancelled(pending, &cancel).await.map_err(SubmitError::Connect)?; + match tablepro_session::persist_pending_save(secrets, writes, save).await { + Ok(report) => Ok((pending, report)), + Err(error) => { + let report = pending.discard().await; + tracing::debug!(?report, "closed a connection whose entry could not be saved"); + Err(SubmitError::Save(error)) + } + } +} + +/// Open a saved connection: read its secrets, ask for what the keyring +/// lacks, connect, then keep what the user typed with Remember on. +/// Returns the secrets the keyring refused to keep. +pub(super) async fn connect_saved( + saved: &SavedConnection, + drivers: &DriverRegistry, + credentials: &KeyringCredentials, + connector: &dyn Connector, + interaction: CredentialInteraction, + cancel: CancellationToken, +) -> Result<(PendingSession, Vec<(SecretKind, SecretError)>), ConnectError> { + // Dropping the resolve on a cancel drops its prompt too, which is + // what takes a password dialog still on screen down with it. + let resolving = tablepro_session::resolve_saved( + saved, + drivers, + credentials, + interaction.clone(), + CredentialDemand::default(), + ); + let refreshed = cancel + .run_until_cancelled(resolving) + .await + .ok_or(ConnectError::Cancelled)??; + let pending = connector + .establish(refreshed.request, interaction, cancel.clone()) + .await?; + let pending = unless_cancelled(pending, &cancel).await?; + let refused = credentials.remember(refreshed.remember).await; + Ok((pending, refused)) +} + +/// A driver blocked in a C call can still connect after the user +/// cancelled. That connection is closed here rather than saved or kept. +async fn unless_cancelled(pending: PendingSession, cancel: &CancellationToken) -> Result { + if !cancel.is_cancelled() { + return Ok(pending); + } + let report = pending.discard().await; + tracing::debug!( + ?report, + "closed a connection that arrived after its connect was cancelled" + ); + Err(ConnectError::Cancelled) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use secrecy::SecretString; + use tablepro_core::credentials::{CredentialPrompter, SecretSave}; + use tablepro_core::{AuthMode, TlsConfig}; + use tablepro_session::connect::StoreConnectionWrites; + use tablepro_session::fakes::{FakeConnector, FakeEstablish}; + use tablepro_storage::{ConnectionStore, StoragePaths}; + use tablepro_test_support::{ConnectionScript, FakeDriver, FakeSecretVault}; + use uuid::Uuid; + + use super::*; + use crate::ui::credential_prompt::GtkCredentialPrompter; + + const LATE: Duration = Duration::from_secs(5); + + fn connection(id: Uuid) -> SavedConnection { + SavedConnection { + id, + name: "Prod".to_owned(), + driver_id: "fake".to_owned(), + host: "db.example.com".to_owned(), + port: 5432, + database: "shop".to_owned(), + username: "app".to_owned(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } + } + + fn save(id: Uuid) -> PendingSave { + PendingSave { + connection: connection(id), + secrets: vec![SecretSave { + kind: SecretKind::DatabasePassword, + secret: SecretString::from("s3cret"), + label: "Prod".to_owned(), + }], + forget: Vec::new(), + is_new: true, + } + } + + fn late_connector() -> FakeConnector { + FakeConnector::new([FakeEstablish::Late { + after: LATE, + script: ConnectionScript::default(), + }]) + } + + fn drivers() -> DriverRegistry { + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(FakeDriver::connecting( + &FakeDriver::NETWORK_CAPABILITIES, + ConnectionScript::default(), + ))); + drivers + } + + #[tokio::test(start_paused = true)] + async fn a_connect_that_lands_after_its_cancel_saves_nothing() { + let root = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.Test")); + let writes = StoreConnectionWrites::new(store.clone(), crate::test_support::paused_tasks()); + let vault = FakeSecretVault::new(); + let connector = late_connector(); + let cancel = CancellationToken::new(); + let request = FakeConnector::request().expect("a request"); + + let connecting = connect_then_save( + &connector, + request, + CredentialInteraction::Unattended, + cancel.clone(), + &vault, + &writes, + save(Uuid::new_v4()), + ); + cancel.cancel(); + let outcome = connecting.await; + + assert!( + matches!(outcome, Err(SubmitError::Connect(ConnectError::Cancelled))), + "{outcome:?}" + ); + assert!(vault.is_empty(), "a cancelled connect stored its secrets"); + assert!( + store.load_blocking().expect("the list").is_empty(), + "a cancelled connect saved its entry" + ); + let built = connector.connections(); + assert_eq!(built.len(), 1); + assert!(built[0].is_closed(), "the late connection was left open"); + } + + #[tokio::test(start_paused = true)] + async fn a_connect_that_is_not_cancelled_saves_its_entry_and_secrets() { + let root = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.Test")); + let writes = StoreConnectionWrites::new(store.clone(), crate::test_support::paused_tasks()); + let vault = FakeSecretVault::new(); + let connector = late_connector(); + let id = Uuid::new_v4(); + + let (pending, report) = connect_then_save( + &connector, + FakeConnector::request().expect("a request"), + CredentialInteraction::Unattended, + CancellationToken::new(), + &vault, + &writes, + save(id), + ) + .await + .expect("a saved connection"); + + assert_eq!(report.saved.id, id); + assert!(!vault.is_empty()); + assert_eq!(store.load_blocking().expect("the list").len(), 1); + let _closed = pending.discard().await; + } + + #[tokio::test(start_paused = true)] + async fn cancelling_while_a_password_is_asked_for_ends_the_prompt() { + let saved = connection(Uuid::new_v4()); + let credentials = KeyringCredentials::for_saved(Arc::new(FakeSecretVault::new()), &saved); + let (prompter, mut queue) = GtkCredentialPrompter::new(); + let prompter: Arc = Arc::new(prompter); + let connector = FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]); + let cancel = CancellationToken::new(); + let drivers = drivers(); + + let connecting = connect_saved( + &saved, + &drivers, + &credentials, + &connector, + CredentialInteraction::Attended(prompter), + cancel.clone(), + ); + let asking = async { + let shown = queue.next().await.expect("the password prompt"); + cancel.cancel(); + shown + }; + let (outcome, shown) = tokio::time::timeout(LATE, async { tokio::join!(connecting, asking) }) + .await + .expect("the cancel did not end the connect while its prompt was up"); + + assert!(matches!(outcome, Err(ConnectError::Cancelled)), "{outcome:?}"); + assert!(shown.reply.is_closed(), "the prompt is still waited on"); + assert!(connector.calls().is_empty(), "a cancelled attempt still connected"); + } + + #[tokio::test(start_paused = true)] + async fn a_saved_connect_that_lands_after_its_cancel_keeps_no_typed_secret() { + let saved = connection(Uuid::new_v4()); + let vault = Arc::new(FakeSecretVault::new()); + let credentials = KeyringCredentials::for_saved(vault.clone(), &saved); + let (prompter, mut queue) = GtkCredentialPrompter::new(); + let prompter: Arc = Arc::new(prompter); + let connector = late_connector(); + let cancel = CancellationToken::new(); + let drivers = drivers(); + + let connecting = connect_saved( + &saved, + &drivers, + &credentials, + &connector, + CredentialInteraction::Attended(prompter), + cancel.clone(), + ); + let answering = async { + let shown = queue.next().await.expect("the password prompt"); + shown + .reply + .send(tablepro_core::credentials::PromptReply::Submitted { + values: vec![SecretString::from("typed")], + remember: true, + }) + .expect("the connect is waiting"); + tokio::time::sleep(LATE / 2).await; + cancel.cancel(); + }; + let (outcome, ()) = tokio::join!(connecting, answering); + + assert!(matches!(outcome, Err(ConnectError::Cancelled)), "{outcome:?}"); + assert!(vault.is_empty(), "a cancelled connect remembered the typed password"); + let built = connector.connections(); + assert_eq!(built.len(), 1); + assert!(built[0].is_closed(), "the late connection was left open"); + } +} diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index d5eb2e0c55..5f8bab5689 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -11,17 +11,17 @@ use tablepro_session::{ ClaimError, ConnectError, CredentialSource, MonitorSignal, PendingSession, Session, SessionClaim, SessionHealth, SessionKey, }; -use tablepro_storage::{CredentialDemand, SavedConnection}; +use tablepro_storage::SavedConnection; use uuid::Uuid; use tablepro_session::RequestTarget; use tablepro_session::connect::StoreConnectionWrites; -use tablepro_storage::StorageError; use crate::ui::connect_dialog::{ConnectDialog, ConnectDialogInit, ConnectPrefill, ConnectSubmission, FormField}; use crate::ui::error_text::TlsRemedy; use crate::ui::ssh_inputs::SshEnvironment; +use super::connect_flow::{self, SubmitError}; use super::teardown_guard::Teardown; use super::{App, AppMsg, qualified_label}; @@ -33,12 +33,6 @@ pub(crate) struct ConnectRetry { pub remedy: Option, } -/// Why a submitted connection did not open. -enum SubmitError { - Connect(ConnectError), - Save(StorageError), -} - /// The password the user typed, so editing after a failure does not /// make them type it again. It stays in memory. fn typed_password(target: &RequestTarget) -> Option { @@ -158,18 +152,16 @@ impl App { let secrets = self.context.secrets.clone(); let writes = StoreConnectionWrites::new(self.storage.connections().clone(), self.tasks.clone()); let connecting = async move { - let pending = connector - .establish(request, interaction, cancel) - .await - .map_err(SubmitError::Connect)?; - match tablepro_session::persist_pending_save(&*secrets, &writes, pending_save).await { - Ok(report) => Ok((pending, report)), - Err(error) => { - let report = pending.discard().await; - tracing::debug!(?report, "closed a connection whose entry could not be saved"); - Err(SubmitError::Save(error)) - } - } + connect_flow::connect_then_save( + &*connector, + request, + interaction, + cancel, + &*secrets, + &writes, + pending_save, + ) + .await }; let context = self.context.clone(); let settings = self.settings.clone(); @@ -810,19 +802,7 @@ impl App { // Reading the keyring, asking for what it lacks and connecting // all happen off the GTK thread; only the outcome comes back. let connecting = async move { - let refreshed = tablepro_session::resolve_saved( - &saved, - &drivers, - &credentials, - interaction.clone(), - CredentialDemand::default(), - ) - .await?; - let pending = connector.establish(refreshed.request, interaction, cancel).await?; - // A secret typed into a prompt is kept only once the login - // it was typed for has worked. - let refused = credentials.remember(refreshed.remember).await; - Ok::<_, ConnectError>((pending, refused)) + connect_flow::connect_saved(&saved, &drivers, &credentials, &*connector, interaction, cancel).await }; crate::runtime::spawn_and_deliver(&self.tasks, connecting, move |outcome| match outcome { Ok(Ok((pending, refused))) => sender.input(AppMsg::SessionReady(Box::new(ReadySession { diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 0221d66c43..19c7c9ebe7 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -1,4 +1,5 @@ mod browse; +mod connect_flow; mod connection; mod import; mod ran_statements; diff --git a/linux/crates/app/src/ui/credential_prompt/presenter.rs b/linux/crates/app/src/ui/credential_prompt/presenter.rs index 8b7b99e739..16e9190074 100644 --- a/linux/crates/app/src/ui/credential_prompt/presenter.rs +++ b/linux/crates/app/src/ui/credential_prompt/presenter.rs @@ -1,7 +1,9 @@ use std::cell::RefCell; +use std::pin::pin; use std::rc::Rc; use adw::prelude::*; +use futures::future::{Either, select}; use libadwaita as adw; use tablepro_core::credentials::PromptReply; use tokio::sync::oneshot; @@ -24,26 +26,45 @@ impl CredentialPromptPresenter { let parent = parent.upcast_ref::().downgrade(); glib::spawn_future_local(async move { while let Some(pending) = queue.next().await { - let PendingPrompt { prompt, reply } = pending; + let PendingPrompt { prompt, mut reply } = pending; if reply.is_closed() { continue; } let answer = match parent.upgrade() { Some(parent) => { let form = PromptForm::build(&prompt); - let response = ask(&form.dialog, &parent).await; + let Some(response) = response_while_wanted(&form.dialog, &parent, &mut reply).await else { + // The connect that asked was cancelled. Its + // dialog goes with it, or the next prompt + // would wait behind one nobody can answer. + form.abandon(); + continue; + }; form.reply(&response) } None => PromptReply::Cancelled, }; - // The connect may have been cancelled while the dialog - // was up; nobody is waiting then, which is fine. + // The asker can still go between the response and here; + // nobody is waiting then, which is fine. let _ = reply.send(answer); } }) } } +/// The response the dialog closes with, or `None` as soon as nobody +/// waits for the answer. +async fn response_while_wanted( + dialog: &adw::AlertDialog, + parent: >k4::Widget, + reply: &mut oneshot::Sender, +) -> Option { + match select(pin!(ask(dialog, parent)), pin!(reply.closed())).await { + Either::Left((response, _)) => Some(response), + Either::Right(((), _)) => None, + } +} + /// Present the dialog and wait for the response it closes with. /// /// The closed signal answers too, as a cancel: a dialog torn down with @@ -137,6 +158,41 @@ mod tests { crate::test_support::drain_main_context(); } + #[gtk4::test] + fn a_prompt_whose_connect_was_cancelled_closes_and_lets_the_next_one_show() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = adw::Window::new(); + window.present(); + let (prompter, queue) = GtkCredentialPrompter::new(); + let presenter = CredentialPromptPresenter::start(queue, &window); + let cancelled = prompter.prompt(prompt("cancelled")); + crate::test_support::wait_until(Duration::from_secs(5), || open_alerts(&window).len() == 1) + .expect("the first prompt"); + + drop(cancelled); + + crate::test_support::wait_until(Duration::from_secs(5), || open_alerts(&window).is_empty()) + .expect("the dialog of a cancelled connect stayed on screen"); + let answered = std::rc::Rc::new(std::cell::Cell::new(false)); + let asking = prompter.prompt(prompt("next")); + let sink = answered.clone(); + glib::spawn_future_local(async move { + asking.await; + sink.set(true); + }); + crate::test_support::wait_until(Duration::from_secs(5), || { + open_alerts(&window).iter().any(|alert| alert.body().contains("next")) + }) + .expect("the next prompt behind the cancelled one"); + for alert in open_alerts(&window) { + alert.close(); + } + crate::test_support::wait_until(Duration::from_secs(5), || answered.get()).expect("the next prompt answered"); + presenter.abort(); + window.close(); + crate::test_support::drain_main_context(); + } + #[gtk4::test] fn a_prompt_whose_dialog_is_forced_shut_is_cancelled() { adw::init().expect("libadwaita initialises under the test backend"); diff --git a/linux/crates/app/src/ui/credential_prompt/prompt_form.rs b/linux/crates/app/src/ui/credential_prompt/prompt_form.rs index 4d160acf32..1983963839 100644 --- a/linux/crates/app/src/ui/credential_prompt/prompt_form.rs +++ b/linux/crates/app/src/ui/credential_prompt/prompt_form.rs @@ -89,6 +89,15 @@ impl PromptForm { Self { dialog, rows, remember } } + /// Close the dialog with nothing left typed in it, for a prompt + /// nobody waits on any more. + pub fn abandon(&self) { + for row in &self.rows { + drop(row.take()); + } + self.dialog.force_close(); + } + /// The reply for the response the dialog closed with. Anything but /// the accept button, including Escape, is a cancel. pub fn reply(&self, response: &str) -> PromptReply { From bd547b67677a702005e00c040ea2031c7c3f7ac2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:49:38 +0700 Subject: [PATCH 046/178] fix(linux-session): end a Retry's password prompt with its session --- .../session/src/health/health_monitor.rs | 77 ++++++++++++++++++- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/linux/crates/session/src/health/health_monitor.rs b/linux/crates/session/src/health/health_monitor.rs index 0f6222c3e7..d2373e6e34 100644 --- a/linux/crates/session/src/health/health_monitor.rs +++ b/linux/crates/session/src/health/health_monitor.rs @@ -160,10 +160,17 @@ async fn reconnect(ctx: &MonitorContext, user_asked: bool) -> Option>>, + } + + impl UnansweredPrompt { + fn still_asking(&self) -> bool { + self.questions + .lock() + .unwrap() + .iter() + .any(|question| !question.is_closed()) + } + } + + impl CredentialSource for UnansweredPrompt { + fn refresh( + &self, + request: ConnectRequest, + _interaction: tablepro_core::credentials::CredentialInteraction, + _demand: CredentialDemand, + ) -> futures::future::BoxFuture<'static, Result> { + let (question, answer) = tokio::sync::oneshot::channel(); + self.questions.lock().unwrap().push(question); + Box::pin(async move { + let _unanswered = answer.await; + Ok(RefreshedRequest { + request, + remember: Vec::new(), + }) + }) + } + } + fn refused() -> ConnectError { ConnectError::Driver(DriverError::Transport(TransportError::Refused { endpoint: NetworkEndpoint::new("db.example.com", 5432).unwrap(), @@ -489,6 +533,33 @@ mod tests { assert!(started.connection.is_closed(), "the replaced connection was left open"); } + #[tokio::test(start_paused = true)] + async fn shutting_down_ends_a_prompt_a_retry_raised() { + let prompt = Arc::new(UnansweredPrompt::default()); + let (pending, _connection) = FakeConnector::pending_session(FakeConnector::request().unwrap(), healthy()); + let connector = Arc::new(FakeConnector::new([])); + let session = Session::start( + pending, + SessionDeps { + connector: connector.clone(), + credentials: prompt.clone(), + deadlines: Arc::new(DefaultDeadlines::new(LivenessPolicy::DESKTOP)), + tasks: paused_tasks(), + prompter: Some(Arc::new(FakePrompter::new([]))), + }, + ); + session.signal(MonitorSignal::RetryNow); + settle().await; + assert!(prompt.still_asking(), "the retry never asked"); + + let report = session.begin_shutdown(); + tokio::time::sleep(Duration::from_secs(30)).await; + + assert!(report.await.is_ok()); + assert!(!prompt.still_asking(), "the prompt outlived the session that raised it"); + assert!(connector.calls().is_empty()); + } + #[tokio::test(start_paused = true)] async fn late_reconnect_after_cancel_is_discarded_and_closed() { let connector = FakeConnector::new([FakeEstablish::Late { From 96ee24a1c97d8d3491aba2ad97147b831e920f89 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 15:49:38 +0700 Subject: [PATCH 047/178] fix(linux): close the session when its window closes --- linux/CHANGELOG.md | 1 + linux/crates/app/src/ui/app/connection.rs | 112 +++++++++++++++++----- linux/crates/app/src/ui/app/mod.rs | 16 +++- 3 files changed, 104 insertions(+), 25 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 09c738f03d..30ce1b9c14 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. - A cancelled connect could still save the connection and its password, and left its password dialog open. +- Closing the window cut the database connection off instead of closing it. ### Security diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index 5f8bab5689..9411ab1656 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -6,10 +6,10 @@ use relm4::{ComponentController, ComponentSender, adw, gtk}; use tablepro_core::meta::ObjectInfo; use tablepro_session::connect::AttemptGeneration; -use tablepro_session::runtime::WatchForwarder; +use tablepro_session::runtime::{ProcessHold, WatchForwarder}; use tablepro_session::{ - ClaimError, ConnectError, CredentialSource, MonitorSignal, PendingSession, Session, SessionClaim, SessionHealth, - SessionKey, + ClaimError, ConnectAttempts, ConnectError, CredentialSource, MonitorSignal, PendingSession, Session, SessionClaim, + SessionHealth, SessionKey, }; use tablepro_storage::SavedConnection; use uuid::Uuid; @@ -17,6 +17,7 @@ use uuid::Uuid; use tablepro_session::RequestTarget; use tablepro_session::connect::StoreConnectionWrites; +use crate::services::active_session::ActiveSession; use crate::ui::connect_dialog::{ConnectDialog, ConnectDialogInit, ConnectPrefill, ConnectSubmission, FormField}; use crate::ui::error_text::TlsRemedy; use crate::ui::ssh_inputs::SshEnvironment; @@ -217,12 +218,8 @@ impl App { }); } - /// The Connecting toast's Cancel. The attempt is superseded, not - /// only cancelled: a driver blocked in a socket call can still - /// connect afterwards, and that late session must not be adopted. pub(super) fn on_cancel_connect(&mut self) { - drop(self.connect_attempts.begin()); - self.connect_attempts.cancel(); + stop_connecting(&self.connect_attempts); self.dismiss_loading_page(); } @@ -429,17 +426,7 @@ impl App { // different connection would target a non-existent table. self.clear_closed_tabs_stack(); self.session_links = None; - if let Some(session) = self.context.active.take() { - let hold = self.context.hold.acquire(); - let closing = session.begin_shutdown(); - glib::spawn_future_local(async move { - match closing.await { - Ok(report) => tracing::debug!(?report, "the session closed"), - Err(failure) => tracing::warn!(%failure, "the session close task failed"), - } - drop(hold); - }); - } + close_session(&self.context.active, &*self.context.hold); self.reconnect_banner.set_revealed(false); self.schema_buffer.set_text(crate::ui::editor::SQL_KEYWORDS); self.current_driver_id = None; @@ -912,9 +899,44 @@ impl App { } } -/// Performs the actual disk + keyring teardown for a saved connection. -/// Extracted from `on_delete_connection` so the confirm-yes branch and -/// the prefs-disabled branch share one implementation. +/// Supersede the connect in flight, not only cancel it: a driver +/// blocked in a socket call can still connect afterwards, and that late +/// session must not be adopted. +fn stop_connecting(attempts: &ConnectAttempts) { + drop(attempts.begin()); + attempts.cancel(); +} + +/// Shut the window's session down and close its connection under the +/// close deadline. The process is held until the close ends, so the +/// server sees the connection closed rather than cut off. +fn close_session(active: &ActiveSession, hold: &dyn ProcessHold) { + let Some(session) = active.take() else { + return; + }; + let hold = hold.acquire(); + let closing = session.begin_shutdown(); + glib::spawn_future_local(async move { + match closing.await { + Ok(report) => tracing::debug!(?report, "the session closed"), + Err(failure) => tracing::warn!(%failure, "the session close task failed"), + } + drop(hold); + }); +} + +/// What a closing window still owes its connection: no connect left to +/// land in it, and the session closed rather than dropped. +/// +/// The window's close request is where this has to start. relm4 calls +/// a detached root's `shutdown` only after the main loop has returned, +/// when no close can run any more and a hold no longer keeps the +/// process alive. +pub(super) fn close_window_session(active: &ActiveSession, hold: &dyn ProcessHold, attempts: &ConnectAttempts) { + stop_connecting(attempts); + close_session(active, hold); +} + /// Move every secret the original holds onto the copy's own id. /// /// Returns whether all of them made it: a keyring that is locked or @@ -1118,11 +1140,55 @@ fn failed_banner_text(cause: tablepro_session::FailureCause) -> String { #[cfg(test)] mod tests { + use std::time::Duration; + + use tablepro_core::LivenessPolicy; use tablepro_core::meta::ObjectKind; - use tablepro_session::FailureCause; + use tablepro_session::fakes::{FakeConnector, FakeCredentialSource}; + use tablepro_session::operation::DefaultDeadlines; + use tablepro_session::runtime::CountingHold; + use tablepro_session::{FailureCause, SessionDeps}; + use tablepro_test_support::ConnectionScript; use super::*; + #[gtk4::test] + fn a_closing_window_closes_its_session_and_stops_its_connect() { + let runtime = crate::test_support::test_runtime(); + let (pending, connection) = FakeConnector::pending_session( + FakeConnector::request().expect("a request"), + ConnectionScript::default(), + ); + let session = Session::start( + pending, + SessionDeps { + connector: Arc::new(FakeConnector::new([])), + credentials: Arc::new(FakeCredentialSource::new()), + deadlines: Arc::new(DefaultDeadlines::new(LivenessPolicy::DESKTOP)), + tasks: runtime.tasks(), + prompter: None, + }, + ); + let active = ActiveSession::new(); + active.replace(session.clone()); + let hold = CountingHold::new(); + let attempts = ConnectAttempts::new(); + let connecting = attempts.begin(); + + close_window_session(&active, &hold, &attempts); + + assert!(connecting.is_cancelled(), "the connect in flight kept running"); + assert!( + !attempts.accepts(connecting.generation()), + "a late connect would still be adopted" + ); + assert!(!active.is_connected()); + assert_eq!(hold.live(), 1, "nothing kept the process alive for the close"); + crate::test_support::wait_until(Duration::from_secs(5), || hold.live() == 0).expect("the close to end"); + assert!(session.is_shut_down()); + assert!(connection.is_closed(), "the connection was cut off rather than closed"); + } + fn object(name: &str, kind: ObjectKind) -> ObjectInfo { ObjectInfo { schema: Some("public".to_owned()), diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 19c7c9ebe7..775615962b 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -73,7 +73,8 @@ pub struct App { /// Ends the credential prompt loop with the window. prompt_presenter: glib::JoinHandle<()>, /// Which connect attempt is current, so a late result is discarded. - connect_attempts: tablepro_session::ConnectAttempts, + /// Shared with the close handler, which stops the one in flight. + connect_attempts: std::rc::Rc, settings: std::rc::Rc, storage: crate::storage::SharedStorage, tasks: tablepro_session::runtime::Tasks, @@ -853,6 +854,9 @@ impl SimpleComponent for App { let in_flight_saves_handle = in_flight_saves.clone(); let in_flight_saves_for_close = in_flight_saves.clone(); let close_request_input_sender = sender.input_sender().clone(); + let connect_attempts = std::rc::Rc::new(tablepro_session::ConnectAttempts::new()); + let context_for_close = context.clone(); + let connect_attempts_for_close = connect_attempts.clone(); widgets.window.connect_close_request(move |w| { // If a Save is mid-flight (async transaction running), block // the close until it resolves. Without this, the completion @@ -966,6 +970,11 @@ impl SimpleComponent for App { return glib::Propagation::Stop; } crate::ui::window_geometry::persist(w, &settings_for_close); + connection::close_window_session( + &context_for_close.active, + &*context_for_close.hold, + &connect_attempts_for_close, + ); // The last column drag or filter change may still be on its // way to disk. The hold keeps the process alive until it // lands, so quitting does not lose it. @@ -1282,7 +1291,7 @@ impl SimpleComponent for App { context, session_links: None, prompt_presenter, - connect_attempts: tablepro_session::ConnectAttempts::new(), + connect_attempts, settings: settings.clone(), storage: storage.clone(), tasks: tasks.clone(), @@ -1364,6 +1373,9 @@ impl SimpleComponent for App { self.history_gate.abort(); self.prompt_presenter.abort(); self.session_links = None; + // The close request already did this; a window destroyed + // without one still stops its session's work here. + connection::close_window_session(&self.context.active, &*self.context.hold, &self.connect_attempts); } fn update(&mut self, msg: Self::Input, sender: ComponentSender) { From eedae9e17f94ee1e60bf224519661c6df5a561c2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 16:02:48 +0700 Subject: [PATCH 048/178] fix(linux-editor): keep a MySQL function against its paren in Format --- linux/CHANGELOG.md | 2 +- linux/crates/app/src/ui/editor/format_plan.rs | 35 ++++++++++-- .../app/src/ui/editor/significant_tokens.rs | 53 +++++++++++++++---- 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 352e836fb2..c36ce4d8f8 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -18,5 +18,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. -- Format could put a space inside `+=`, `b'01'` or `@@version` and change what the statement does. +- Format could put a space inside `+=`, `b'01'` or `@@version`, or between a MySQL function such as `GROUP_CONCAT` and its parenthesis, and change what the statement does. - Opening a pipe or a device as a SQL file never finished, or read past the 8 MB limit. diff --git a/linux/crates/app/src/ui/editor/format_plan.rs b/linux/crates/app/src/ui/editor/format_plan.rs index ce95aec534..d0e99e1e2f 100644 --- a/linux/crates/app/src/ui/editor/format_plan.rs +++ b/linux/crates/app/src/ui/editor/format_plan.rs @@ -9,11 +9,11 @@ use super::significant_tokens::significant_tokens; /// sqlformat has its own lexer, and it does not know every dialect the /// app connects to: it reads `//` as an operator, ends a nested block /// comment at the first `*/`, treats a backslash as an escape inside -/// quotes, takes `$$` for a keyword, and puts a space inside `+=` and -/// `b'01'`. Reformatting through it can therefore change what a -/// statement means. Each statement is checked against its own tokens -/// afterwards, and one that came back different is written out as the -/// user typed it. +/// quotes, takes `$$` for a keyword, and puts a space inside `+=`, +/// `b'01'` and `GROUP_CONCAT(`. Reformatting through it can therefore +/// change what a statement means. Each statement is checked against +/// its own tokens afterwards, and one that came back different is +/// written out as the user typed it. pub fn format_script(text: &str, grammar: SqlGrammar, settings: LexicalSettings) -> String { let plan = ScriptPlan::build(text, grammar, settings); let mut out = String::with_capacity(text.len()); @@ -186,6 +186,31 @@ mod tests { } } + #[test] + fn a_mysql_call_keeps_its_name_against_its_paren() { + // sqlformat writes `GROUP_CONCAT (a)`, which MySQL reads as a + // stored function of that name. + let out = format("SELECT GROUP_CONCAT(a) FROM t", SqlGrammar::MySql); + + assert!( + out.contains("GROUP_CONCAT(a)"), + "the call was split from its paren: {out}" + ); + } + + #[test] + fn mysql_statements_with_untouched_calls_are_still_formatted() { + for source in [ + "select count(*), max(a) from t", + "insert into t (a, b) values (1, 2)", + "select ifnull(a, 0) from t where a in(1, 2)", + ] { + let out = format(source, SqlGrammar::MySql); + + assert!(out.contains('\n'), "the statement was not reflowed: {out}"); + } + } + #[test] fn an_empty_script_stays_empty() { assert_eq!(format("", SqlGrammar::PostgreSql), ""); diff --git a/linux/crates/app/src/ui/editor/significant_tokens.rs b/linux/crates/app/src/ui/editor/significant_tokens.rs index 83a943a2f0..6d4586bfd0 100644 --- a/linux/crates/app/src/ui/editor/significant_tokens.rs +++ b/linux/crates/app/src/ui/editor/significant_tokens.rs @@ -23,9 +23,9 @@ pub fn significant_tokens(text: &str, grammar: SqlGrammar, settings: LexicalSett ScriptTokenKind::LineComment | ScriptTokenKind::BlockComment => slice.trim_end(), _ => slice, }; - let gap = tokens - .last() - .map_or(Gap::Free, |previous| gap_between(previous, token.kind, body, spacing)); + let gap = tokens.last().map_or(Gap::Free, |previous| { + gap_between(grammar, previous, token.kind, body, spacing) + }); tokens.push(SignificantToken { kind: token.kind, text: body.to_owned(), @@ -40,7 +40,6 @@ pub fn significant_tokens(text: &str, grammar: SqlGrammar, settings: LexicalSett pub struct SignificantToken { kind: ScriptTokenKind, text: String, - /// What separates it from the token before it. gap: Gap, } @@ -60,13 +59,23 @@ enum Gap { LineBreak, } +/// Words sqlformat spaces from their paren that MySQL 8.4 and MariaDB +/// 11.4 were checked to read the same either way. +const MYSQL_SPACED_CALLS: [&str; 7] = ["EXISTS", "IF", "IFNULL", "IN", "REPLACE", "USING", "VALUES"]; + #[derive(Debug, Clone, Copy, Default)] struct Spacing { spaced: bool, line_break: bool, } -fn gap_between(previous: &SignificantToken, kind: ScriptTokenKind, text: &str, spacing: Spacing) -> Gap { +fn gap_between( + grammar: SqlGrammar, + previous: &SignificantToken, + kind: ScriptTokenKind, + text: &str, + spacing: Spacing, +) -> Gap { let strings = previous.kind == ScriptTokenKind::StringLiteral && kind == ScriptTokenKind::StringLiteral; if strings && spacing.line_break { return Gap::LineBreak; @@ -75,16 +84,25 @@ fn gap_between(previous: &SignificantToken, kind: ScriptTokenKind, text: &str, s && kind == ScriptTokenKind::Punct && previous.text != "," && text != ","; + // Without IGNORE_SPACE, MySQL reads `count(` as the built-in and + // `count (` as a stored function named count. + let call = grammar == SqlGrammar::MySql + && previous.kind == ScriptTokenKind::Word + && kind == ScriptTokenKind::OpenParen + && !MYSQL_SPACED_CALLS + .iter() + .any(|word| word.eq_ignore_ascii_case(&previous.text)); let free = spacing_is_free(previous.kind, &previous.text) || spacing_is_free(kind, text); - if !strings && !operator && free { + if !strings && !operator && !call && free { return Gap::Free; } if spacing.spaced { Gap::Spaced } else { Gap::Touching } } -/// Whether spacing next to this token never changes what an engine -/// reads: a bracket, a separator, a comment, or an operator character -/// that is never part of a name, a literal or a parameter. +/// Whether spacing next to this token changes nothing an engine reads, +/// outside the pairs `gap_between` checks first: a bracket, a +/// separator, a comment, or an operator character that is never part +/// of a name, a literal or a parameter. fn spacing_is_free(kind: ScriptTokenKind, text: &str) -> bool { match kind { ScriptTokenKind::OpenParen @@ -151,6 +169,23 @@ mod tests { ); } + #[test] + fn a_space_before_a_mysql_call_changes_the_statement() { + assert_ne!( + tokens_for("select count(a) from t", SqlGrammar::MySql), + tokens_for("select count (a) from t", SqlGrammar::MySql) + ); + assert_eq!( + tokens_for("select count(a) from t", SqlGrammar::MySql), + tokens_for("select\n count(a)\nfrom t", SqlGrammar::MySql) + ); + assert_eq!(tokens("select count(a) from t"), tokens("select count (a) from t")); + assert_eq!( + tokens_for("select ifnull(a, 0) from t where a in(1)", SqlGrammar::MySql), + tokens_for("select ifnull (a, 0) from t where a in (1)", SqlGrammar::MySql) + ); + } + #[test] fn strings_joined_onto_one_line_change_the_statement() { assert_ne!(tokens("select 'a'\n'b'"), tokens("select 'a' 'b'")); From 1ccec23eb3a23fa4870d05b20cd592c2a826bd70 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 16:02:51 +0700 Subject: [PATCH 049/178] test(linux-editor): cover Run at Cursor while a run is in flight --- linux/crates/app/src/ui/editor/tests.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 6e05926ee6..8db240ce2f 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -110,6 +110,28 @@ fn run_at_cursor_selects_executed_statement() { assert_eq!((start.offset(), end.offset()), (14, 29)); } +#[gtk4::test] +fn run_at_cursor_during_a_run_leaves_the_buffer_alone() { + let mut runs = Replies::default(); + runs.always(FakeRun::until_stopped()); + let harness = editor_with(runs, "SELECT pg_sleep(60);\nSELECT 2;"); + let buffer = harness.editor.buffer(); + harness.editor.run(); + wait_until(WAIT, || !harness.fake.texts().is_empty()).expect("the first statement to start"); + let sent_before = sent(&harness.fake); + buffer.place_cursor(&buffer.iter_at_offset(23)); + + harness.editor.run_at_cursor(); + + assert!( + buffer.selection_bounds().is_none(), + "a statement that did not run was selected" + ); + assert_eq!(sent(&harness.fake), sent_before); + harness.editor.stop(); + wait_until(WAIT, || !harness.editor.is_running()).expect("the run to end"); +} + #[gtk4::test] fn a_script_fills_the_results_list_one_line_per_statement() { let mut runs = Replies::default(); From 7cf534da5f68cbe2c1e253042c1a2186946716a7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 16:02:55 +0700 Subject: [PATCH 050/178] refactor(linux-editor): drop comments that restate the results code --- linux/crates/app/src/ui/editor/mod.rs | 2 -- linux/crates/app/src/ui/editor/results_pane.rs | 8 ++------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index aaaa34f95c..92020be2a4 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -412,7 +412,6 @@ impl SqlEditor { self.show_new_entries(before); } - /// Add the log's lines from `from` on to the results list. fn show_new_entries(&self, from: usize) { let imp = self.imp(); let log = imp.log.borrow(); @@ -422,7 +421,6 @@ impl SqlEditor { imp.results.append(log.entries().get(from..).unwrap_or_default()); } - /// The page the results list shows for the log's line `index`. fn result_page(&self, index: u32) -> Option { let imp = self.imp(); let grid_sender = imp.grid_sender.get()?; diff --git a/linux/crates/app/src/ui/editor/results_pane.rs b/linux/crates/app/src/ui/editor/results_pane.rs index efddec4d90..82cae94399 100644 --- a/linux/crates/app/src/ui/editor/results_pane.rs +++ b/linux/crates/app/src/ui/editor/results_pane.rs @@ -140,9 +140,8 @@ impl ResultsPane { imp.transaction_label.set_visible(false); } - /// What builds a line's page. A page is built when its line is - /// first shown, so a script with thousands of statements builds - /// one, not thousands. + /// A page is built when its line is first shown, so a script with + /// thousands of statements builds one, not thousands. pub fn set_page_builder(&self, builder: impl Fn(u32) -> Option + 'static) { self.imp().page_builder.replace(Some(Box::new(builder))); } @@ -161,7 +160,6 @@ impl ResultsPane { imp.transaction_label.set_visible(label.is_some()); } - /// Add the lines for `entries`, which follow the ones listed. pub fn append(&self, entries: &[RunLogEntry]) { let imp = self.imp(); let Some(items) = imp.items.get() else { @@ -226,8 +224,6 @@ impl ResultsPane { } } - /// Build the line's page unless it is kept already, letting go of - /// the page shown longest ago once more than `PAGES_KEPT` are kept. fn keep_page(&self, index: u32) -> bool { let imp = self.imp(); { From 037dba185f7fdd19b37244753a353aaf8d61dc0c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 16:06:37 +0700 Subject: [PATCH 051/178] fix(linux-ssh): leave a config's StrictHostKeyChecking yes in force --- linux/crates/ssh/src/argv.rs | 80 +++++++++++++++-------- linux/crates/ssh/src/first_hop.rs | 42 +++++------- linux/crates/ssh/src/host_key_checking.rs | 52 +++++++++++++++ linux/crates/ssh/src/lib.rs | 1 + linux/crates/ssh/src/password_owner.rs | 12 +--- linux/crates/ssh/src/session.rs | 13 ++-- linux/crates/ssh/tests/openssh_session.rs | 24 ++++++- 7 files changed, 154 insertions(+), 70 deletions(-) create mode 100644 linux/crates/ssh/src/host_key_checking.rs diff --git a/linux/crates/ssh/src/argv.rs b/linux/crates/ssh/src/argv.rs index 6cf69ef331..441791cbfb 100644 --- a/linux/crates/ssh/src/argv.rs +++ b/linux/crates/ssh/src/argv.rs @@ -4,16 +4,15 @@ use std::path::Path; use tablepro_core::{LivenessPolicy, NetworkEndpoint}; +use crate::host_key_checking::HostKeyChecking; use crate::{SshAuth, SshConfig}; -const FORCED_OPTIONS: [&str; 15] = [ +const FORCED_OPTIONS: [&str; 14] = [ "ControlMaster=yes", "ControlPersist=no", "ForkAfterAuthentication=no", "StreamLocalBindMask=0177", "BatchMode=no", - // The app's host-key dialog is the trust decision, so a user config's accept-new or no must not skip it. - "StrictHostKeyChecking=ask", "VisualHostKey=no", "Tunnel=no", "ClearAllForwardings=yes", @@ -39,12 +38,21 @@ pub(crate) enum ControlOp<'a> { Exit, } -pub(crate) fn master_args(config: &SshConfig, control: &Path, liveness: &LivenessPolicy) -> Vec { +pub(crate) fn master_args( + config: &SshConfig, + control: &Path, + liveness: &LivenessPolicy, + host_keys: Option, +) -> Vec { let mut args: Vec = ["-M", "-N", "-T"].into_iter().map(OsString::from).collect(); push_option(&mut args, control_path_option(control)); for option in &FORCED_OPTIONS[..FORCED_OPTIONS.len() - 1] { push_option(&mut args, OsString::from(option)); } + // The app's dialog is the trust decision, so accept-new and no are raised to ask; a config's yes stays a refusal. + if matches!(host_keys, Some(HostKeyChecking::AcceptNew | HostKeyChecking::No)) { + push_option(&mut args, OsString::from("StrictHostKeyChecking=ask")); + } push_option( &mut args, format!("ConnectTimeout={}", liveness.connect_timeout.as_secs()).into(), @@ -165,6 +173,7 @@ mod tests { &config(None, None, SshAuth::Agent), Path::new("/run/user/1000/app/ssh/0123456789abcdef/89abcdef/control"), &LivenessPolicy::DESKTOP, + None, ); assert_eq!( strings(&args), @@ -185,8 +194,6 @@ mod tests { "-o", "BatchMode=no", "-o", - "StrictHostKeyChecking=ask", - "-o", "VisualHostKey=no", "-o", "Tunnel=no", @@ -217,29 +224,39 @@ mod tests { } #[test] - fn every_auth_mode_leaves_the_host_key_question_to_the_app() { - let auths = [ - SshAuth::Agent, - SshAuth::PrivateKey { - path: None, - passphrase: None, - }, - SshAuth::Password { - password: SecretString::from("secret"), - }, - SshAuth::KeyboardInteractive, + fn only_a_config_that_would_skip_the_host_key_dialog_is_overridden() { + let cases = [ + (None, None), + (Some(HostKeyChecking::Yes), None), + (Some(HostKeyChecking::Ask), None), + (Some(HostKeyChecking::AcceptNew), Some("StrictHostKeyChecking=ask")), + (Some(HostKeyChecking::No), Some("StrictHostKeyChecking=ask")), ]; - for auth in auths { - let args = master_args( - &config(None, None, auth), - Path::new("/tmp/control"), - &LivenessPolicy::DESKTOP, - ); - let strict: Vec<&str> = strings(&args) - .into_iter() - .filter(|arg| arg.starts_with("StrictHostKeyChecking=")) - .collect(); - assert_eq!(strict, ["StrictHostKeyChecking=ask"]); + for (host_keys, expected) in cases { + let auths = [ + SshAuth::Agent, + SshAuth::PrivateKey { + path: None, + passphrase: None, + }, + SshAuth::Password { + password: SecretString::from("secret"), + }, + SshAuth::KeyboardInteractive, + ]; + for auth in auths { + let args = master_args( + &config(None, None, auth), + Path::new("/tmp/control"), + &LivenessPolicy::DESKTOP, + host_keys, + ); + let strict: Vec<&str> = strings(&args) + .into_iter() + .filter(|arg| arg.starts_with("StrictHostKeyChecking=")) + .collect(); + assert_eq!(strict, expected.into_iter().collect::>(), "{host_keys:?}"); + } } } @@ -250,6 +267,7 @@ mod tests { &config(None, None, SshAuth::Agent), control, &LivenessPolicy::DESKTOP, + None, )) .join(" "); assert!(!without.contains(" -p ") && !without.contains(" -l ")); @@ -257,6 +275,7 @@ mod tests { &config(Some(2222), Some("deploy"), SshAuth::Agent), control, &LivenessPolicy::DESKTOP, + None, ); assert_eq!( strings(&with[with.len() - 6..]), @@ -268,7 +287,7 @@ mod tests { fn jump_hosts_render_one_minus_j() { let mut with_jumps = config(None, None, SshAuth::Agent); with_jumps.jump_hosts = SshDestination::parse_jump_list("ops@jump1:2200,[fd00::1]").unwrap(); - let args = master_args(&with_jumps, Path::new("/tmp/control"), &LivenessPolicy::DESKTOP); + let args = master_args(&with_jumps, Path::new("/tmp/control"), &LivenessPolicy::DESKTOP, None); let args = strings(&args); assert_eq!(args.iter().filter(|arg| **arg == "-J").count(), 1); let position = args.iter().position(|arg| *arg == "-J").unwrap(); @@ -285,6 +304,7 @@ mod tests { &config(None, None, auth), Path::new("/tmp/control"), &LivenessPolicy::DESKTOP, + None, ); let joined = strings(&args).join(" "); assert!( @@ -300,6 +320,7 @@ mod tests { &config(None, None, without_path), Path::new("/tmp/control"), &LivenessPolicy::DESKTOP, + None, ); assert!(!strings(&args).contains(&"-i")); } @@ -324,6 +345,7 @@ mod tests { &config(None, None, auth), Path::new("/tmp/control"), &LivenessPolicy::DESKTOP, + None, ); let preferred = strings(&args) .into_iter() diff --git a/linux/crates/ssh/src/first_hop.rs b/linux/crates/ssh/src/first_hop.rs index 4aeb3d8109..f6abd116e9 100644 --- a/linux/crates/ssh/src/first_hop.rs +++ b/linux/crates/ssh/src/first_hop.rs @@ -6,7 +6,7 @@ use std::time::Duration; use tablepro_core::NetworkEndpoint; use tokio::process::Command; -use crate::{SshConfig, SshDestination}; +use crate::SshDestination; /// `ssh -G` does no network I/O, but a `Match exec` line can run a /// command, so each lookup is still bounded. @@ -23,27 +23,23 @@ enum Resolved { ProxyCommand, } -/// The host and port the first TCP connection of a session goes to. +/// The host and port the first TCP connection of a session goes to, +/// from what `ssh -G` printed for the session's destination. /// /// An ssh_config alias is only a name until `ssh -G` resolves it, and a /// jump chain starts at its first hop, so this asks ssh rather than /// trusting what the user typed. `None` when the route is a /// ProxyCommand, whose first hop only that command knows. -pub(crate) async fn resolve_first_hop(ssh_program: &Path, config: &SshConfig) -> Option { - let mut destination = config.destination.clone(); - let mut jumps = config.jump_hosts.clone(); +pub(crate) async fn resolve_first_hop(ssh_program: &Path, destination: Option<&str>) -> Option { + let mut output = destination?.to_owned(); for _ in 0..MAX_HOPS { - let output = lookup(ssh_program, &destination, &jumps).await?; match parse_resolved(&output)? { Resolved::Direct(endpoint) => return Some(endpoint), Resolved::ProxyCommand => return None, - Resolved::Jump(first) => { - destination = first; - jumps = Vec::new(); - } + Resolved::Jump(first) => output = lookup(ssh_program, &first, &[]).await?, } } - tracing::debug!(destination = %config.destination, "the ssh jump chain did not end"); + tracing::debug!("the ssh jump chain did not end"); None } @@ -130,7 +126,6 @@ mod tests { use std::path::PathBuf; use super::*; - use crate::SshAuth; fn endpoint(host: &str, port: u16) -> NetworkEndpoint { NetworkEndpoint::new(host, port).unwrap() @@ -196,15 +191,14 @@ mod tests { program } - fn config(host: &str, jumps: &[&str]) -> SshConfig { - SshConfig { - destination: SshDestination::new(host, None, None).unwrap(), - jump_hosts: jumps - .iter() - .map(|jump| SshDestination::new(*jump, None, None).unwrap()) - .collect(), - auth: SshAuth::Agent, - } + async fn first_hop(program: &Path, host: &str, jumps: &[&str]) -> Option { + let destination = SshDestination::new(host, None, None).unwrap(); + let jumps: Vec = jumps + .iter() + .map(|jump| SshDestination::new(*jump, None, None).unwrap()) + .collect(); + let resolved = lookup(program, &destination, &jumps).await; + resolve_first_hop(program, resolved.as_deref()).await } /// The fake prints a jump for the database host and resolves the @@ -222,7 +216,7 @@ case "$last" in esac"#, ); - let first = resolve_first_hop(&program, &config("db", &["bastion"])).await; + let first = first_hop(&program, "db", &["bastion"]).await; assert_eq!(first, Some(endpoint("192.0.2.10", 2222))); } @@ -231,10 +225,10 @@ esac"#, async fn a_failing_lookup_and_a_jump_loop_resolve_to_nothing() { let temp = tempfile::tempdir().unwrap(); let failing = fake_ssh(temp.path(), "exit 255"); - assert_eq!(resolve_first_hop(&failing, &config("db", &[])).await, None); + assert_eq!(first_hop(&failing, "db", &[]).await, None); let looping = tempfile::tempdir().unwrap(); let program = fake_ssh(looping.path(), r"printf 'hostname x\nproxyjump loop\n'"); - assert_eq!(resolve_first_hop(&program, &config("loop", &[])).await, None); + assert_eq!(first_hop(&program, "loop", &[]).await, None); } } diff --git a/linux/crates/ssh/src/host_key_checking.rs b/linux/crates/ssh/src/host_key_checking.rs new file mode 100644 index 0000000000..3d301e6703 --- /dev/null +++ b/linux/crates/ssh/src/host_key_checking.rs @@ -0,0 +1,52 @@ +/// The StrictHostKeyChecking value the user's ssh config gives a +/// destination. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HostKeyChecking { + Yes, + Ask, + AcceptNew, + No, +} + +impl HostKeyChecking { + /// `ssh -G` prints the value as `true`, `false`, `ask` or + /// `accept-new`, whichever spelling the config used. + pub(crate) fn from_ssh_g(ssh_g_output: &str) -> Option { + let value = ssh_g_output + .lines() + .find_map(|line| line.strip_prefix("stricthostkeychecking "))?; + match value.trim() { + "true" => Some(Self::Yes), + "ask" => Some(Self::Ask), + "accept-new" => Some(Self::AcceptNew), + "false" => Some(Self::No), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_value_ssh_g_prints_is_read() { + let cases = [ + ("true", HostKeyChecking::Yes), + ("ask", HostKeyChecking::Ask), + ("accept-new", HostKeyChecking::AcceptNew), + ("false", HostKeyChecking::No), + ]; + for (printed, expected) in cases { + let output = format!("user deploy\nhostname 10.0.0.5\nstricthostkeychecking {printed}\nport 22\n"); + + assert_eq!(HostKeyChecking::from_ssh_g(&output), Some(expected), "{printed}"); + } + } + + #[test] + fn a_missing_or_unknown_value_is_not_guessed() { + assert_eq!(HostKeyChecking::from_ssh_g("hostname 10.0.0.5\n"), None); + assert_eq!(HostKeyChecking::from_ssh_g("stricthostkeychecking sometimes\n"), None); + } +} diff --git a/linux/crates/ssh/src/lib.rs b/linux/crates/ssh/src/lib.rs index 038aa20dc5..deba84c29f 100644 --- a/linux/crates/ssh/src/lib.rs +++ b/linux/crates/ssh/src/lib.rs @@ -6,6 +6,7 @@ mod config; mod destination; mod first_hop; mod forward; +mod host_key_checking; mod known_hosts; mod password_owner; mod prompt; diff --git a/linux/crates/ssh/src/password_owner.rs b/linux/crates/ssh/src/password_owner.rs index 6dfa0d3ae0..8362705560 100644 --- a/linux/crates/ssh/src/password_owner.rs +++ b/linux/crates/ssh/src/password_owner.rs @@ -1,19 +1,9 @@ -use std::path::Path; - -use crate::SshConfig; -use crate::first_hop::lookup; - /// The `user@host` OpenSSH puts in the destination's password prompt, so /// a stored password goes to that prompt and never to a jump host's. /// /// sshconnect2.c prints `%.30s@%.128s's password: ` with the server user /// and HostKeyAlias, or the resolved HostName when there is no alias. -pub(crate) async fn resolve_password_owner(ssh_program: &Path, config: &SshConfig) -> Option { - let output = lookup(ssh_program, &config.destination, &config.jump_hosts).await?; - password_owner(&output) -} - -fn password_owner(ssh_g_output: &str) -> Option { +pub(crate) fn password_owner(ssh_g_output: &str) -> Option { let mut user = None; let mut hostname = None; let mut alias = None; diff --git a/linux/crates/ssh/src/session.rs b/linux/crates/ssh/src/session.rs index 9bc07dc1c5..e9993c00db 100644 --- a/linux/crates/ssh/src/session.rs +++ b/linux/crates/ssh/src/session.rs @@ -18,9 +18,10 @@ use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; use crate::argv::{ControlOp, master_args}; use crate::askpass_bridge::AskpassBridge; -use crate::first_hop::resolve_first_hop; +use crate::first_hop::{lookup, resolve_first_hop}; +use crate::host_key_checking::HostKeyChecking; use crate::known_hosts::spawn_failure; -use crate::password_owner::resolve_password_owner; +use crate::password_owner::password_owner; use crate::stderr_classify::{ClassifyContext, classify}; use crate::supervisor::{ForwardCancel, Supervisor, TASKS, run_control}; use crate::{SshAuth, SshConfig, SshServices, lock}; @@ -72,12 +73,14 @@ impl SshSession { return Err(error); } }; + let resolved = lookup(&services.ssh_program, &config.destination, &config.jump_hosts).await; let password_owner = match config.auth { - SshAuth::Password { .. } => resolve_password_owner(&services.ssh_program, config).await, + SshAuth::Password { .. } => resolved.as_deref().and_then(password_owner), SshAuth::Agent | SshAuth::PrivateKey { .. } | SshAuth::KeyboardInteractive => None, }; + let host_keys = resolved.as_deref().and_then(HostKeyChecking::from_ssh_g); let spawned = Command::new(&services.ssh_program) - .args(master_args(config, &control, liveness)) + .args(master_args(config, &control, liveness, host_keys)) .env("SSH_ASKPASS", &services.askpass_program) .env("SSH_ASKPASS_REQUIRE", "force") .env_remove("SSH_ASKPASS_PROMPT") @@ -117,7 +120,7 @@ impl SshSession { liveness, &cancel, ), - resolve_first_hop(&services.ssh_program, config), + resolve_first_hop(&services.ssh_program, resolved.as_deref()), ); drop(listener); if let Err(error) = tokio::fs::remove_file(master_dir.join("askpass")).await { diff --git a/linux/crates/ssh/tests/openssh_session.rs b/linux/crates/ssh/tests/openssh_session.rs index 3ff4a8932e..d99c71af69 100644 --- a/linux/crates/ssh/tests/openssh_session.rs +++ b/linux/crates/ssh/tests/openssh_session.rs @@ -34,7 +34,6 @@ struct Fixture { services: SshServices, } -/// The user's ssh config, with the host-key policy they chose. fn write_config(config: &Path, known_hosts: &Path, strict_host_key_checking: &str) -> std::io::Result<()> { std::fs::write( config, @@ -244,6 +243,29 @@ async fn a_user_config_that_skips_the_host_key_check_still_asks() { } } +#[tokio::test] +#[ignore = "requires docker"] +async fn a_user_config_that_requires_a_known_key_refuses_without_asking() { + let fixture = start().await.unwrap(); + write_config(&fixture.config, &fixture.known_hosts, "yes").unwrap(); + + let (session, prompter) = connect(&fixture, password_auth(), Vec::new()).await; + + assert!( + matches!( + session, + Err(TransportError::Ssh(SshFailure::HostKeyUnknown { + trust_allowed: false, + declined: false, + .. + })) + ), + "{session:?}" + ); + assert!(prompter.requests().is_empty(), "{:?}", prompter.requests()); + assert!(!fixture.known_hosts.exists(), "the key was trusted"); +} + #[tokio::test] #[ignore = "requires docker"] async fn wrong_password_is_authentication_rejected() { From 350183de22e5ce5406ecf604348d967c815d80dc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 16:14:19 +0700 Subject: [PATCH 052/178] fix(linux): take back a new entry saved as its connect is cancelled --- linux/crates/app/src/ui/app/connect_flow.rs | 131 ++++++++++++++++-- .../session/src/connect/connection_writes.rs | 13 ++ .../src/connect/persist_pending_save.rs | 5 + 3 files changed, 141 insertions(+), 8 deletions(-) diff --git a/linux/crates/app/src/ui/app/connect_flow.rs b/linux/crates/app/src/ui/app/connect_flow.rs index cf974f9a3a..6701894e9d 100644 --- a/linux/crates/app/src/ui/app/connect_flow.rs +++ b/linux/crates/app/src/ui/app/connect_flow.rs @@ -1,7 +1,7 @@ //! The work behind a connect the window starts, run off the GTK thread. //! //! Every step stops at the attempt's cancel: a cancelled connect asks -//! for nothing more, keeps no secret and saves no entry. +//! for nothing more and adds no entry or secret. use tablepro_core::DriverRegistry; use tablepro_core::credentials::{CredentialInteraction, SecretError, SecretKind, SecretVault}; @@ -11,6 +11,7 @@ use tablepro_session::{ }; use tablepro_storage::{CredentialDemand, KeyringCredentials, SavedConnection, StorageError}; use tokio_util::sync::CancellationToken; +use uuid::Uuid; /// Why a submitted connection did not open. #[derive(Debug)] @@ -36,19 +37,41 @@ pub(super) async fn connect_then_save( .await .map_err(SubmitError::Connect)?; let pending = unless_cancelled(pending, &cancel).await.map_err(SubmitError::Connect)?; - match tablepro_session::persist_pending_save(secrets, writes, save).await { - Ok(report) => Ok((pending, report)), + let is_new = save.is_new; + let report = match tablepro_session::persist_pending_save(secrets, writes, save).await { + Ok(report) => report, Err(error) => { let report = pending.discard().await; tracing::debug!(?report, "closed a connection whose entry could not be saved"); - Err(SubmitError::Save(error)) + return Err(SubmitError::Save(error)); } + }; + // A keyring unlock prompt can hold the save up long enough for the + // user to cancel. An overwritten entry has no earlier version left + // to restore, so only a new one is taken back. + if is_new && cancel.is_cancelled() { + unsave(secrets, writes, report.saved.id).await; + } + unless_cancelled(pending, &cancel) + .await + .map(|pending| (pending, report)) + .map_err(SubmitError::Connect) +} + +async fn unsave(secrets: &dyn SecretVault, writes: &dyn ConnectionWrites, id: Uuid) { + // A secret with no entry is orphaned in the keyring, while an + // entry with no secrets only asks for them again. + if let Err(error) = secrets.delete_connection(id).await { + tracing::warn!(%error, "kept a cancelled connect's entry, whose secrets could not be deleted"); + return; + } + if let Err(error) = writes.remove(id).await { + tracing::warn!(%error, "a cancelled connect's entry is still saved"); } } -/// Open a saved connection: read its secrets, ask for what the keyring -/// lacks, connect, then keep what the user typed with Remember on. -/// Returns the secrets the keyring refused to keep. +/// A secret typed into a prompt is kept only once the login it was +/// typed for has worked. pub(super) async fn connect_saved( saved: &SavedConnection, drivers: &DriverRegistry, @@ -97,6 +120,7 @@ mod tests { use std::sync::Arc; use std::time::Duration; + use futures::future::BoxFuture; use secrecy::SecretString; use tablepro_core::credentials::{CredentialPrompter, SecretSave}; use tablepro_core::{AuthMode, TlsConfig}; @@ -104,7 +128,6 @@ mod tests { use tablepro_session::fakes::{FakeConnector, FakeEstablish}; use tablepro_storage::{ConnectionStore, StoragePaths}; use tablepro_test_support::{ConnectionScript, FakeDriver, FakeSecretVault}; - use uuid::Uuid; use super::*; use crate::ui::credential_prompt::GtkCredentialPrompter; @@ -150,6 +173,22 @@ mod tests { }]) } + struct CancelledWhileSaving { + writes: StoreConnectionWrites, + cancel: CancellationToken, + } + + impl ConnectionWrites for CancelledWhileSaving { + fn upsert(&self, connection: SavedConnection) -> BoxFuture<'_, Result<(), StorageError>> { + self.cancel.cancel(); + self.writes.upsert(connection) + } + + fn remove(&self, id: Uuid) -> BoxFuture<'_, Result<(), StorageError>> { + self.writes.remove(id) + } + } + fn drivers() -> DriverRegistry { let mut drivers = DriverRegistry::new(); drivers.register(Arc::new(FakeDriver::connecting( @@ -222,6 +261,82 @@ mod tests { let _closed = pending.discard().await; } + #[tokio::test(start_paused = true)] + async fn a_new_entry_saved_as_its_connect_is_cancelled_is_taken_back() { + let root = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.Test")); + let cancel = CancellationToken::new(); + let writes = CancelledWhileSaving { + writes: StoreConnectionWrites::new(store.clone(), crate::test_support::paused_tasks()), + cancel: cancel.clone(), + }; + let vault = FakeSecretVault::new(); + let connector = FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]); + + let outcome = connect_then_save( + &connector, + FakeConnector::request().expect("a request"), + CredentialInteraction::Unattended, + cancel, + &vault, + &writes, + save(Uuid::new_v4()), + ) + .await; + + assert!( + matches!(outcome, Err(SubmitError::Connect(ConnectError::Cancelled))), + "{outcome:?}" + ); + assert!(vault.is_empty(), "the cancelled connect's secrets stayed"); + assert!( + store.load_blocking().expect("the list").is_empty(), + "the cancelled connect's entry stayed" + ); + let built = connector.connections(); + assert_eq!(built.len(), 1); + assert!(built[0].is_closed(), "the connection was left open"); + } + + #[tokio::test(start_paused = true)] + async fn an_existing_entry_saved_as_its_connect_is_cancelled_keeps_the_edit() { + let root = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.Test")); + let id = Uuid::new_v4(); + store.upsert_blocking(connection(id)).expect("the saved entry"); + let cancel = CancellationToken::new(); + let writes = CancelledWhileSaving { + writes: StoreConnectionWrites::new(store.clone(), crate::test_support::paused_tasks()), + cancel: cancel.clone(), + }; + let vault = FakeSecretVault::new(); + let connector = FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]); + let mut edit = save(id); + edit.connection.name = "Prod (edited)".to_owned(); + edit.is_new = false; + + let outcome = connect_then_save( + &connector, + FakeConnector::request().expect("a request"), + CredentialInteraction::Unattended, + cancel, + &vault, + &writes, + edit, + ) + .await; + + assert!( + matches!(outcome, Err(SubmitError::Connect(ConnectError::Cancelled))), + "{outcome:?}" + ); + let list = store.load_blocking().expect("the list"); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "Prod (edited)"); + assert!(!vault.is_empty(), "the entry lost its secrets"); + assert!(connector.connections()[0].is_closed(), "the connection was left open"); + } + #[tokio::test(start_paused = true)] async fn cancelling_while_a_password_is_asked_for_ends_the_prompt() { let saved = connection(Uuid::new_v4()); diff --git a/linux/crates/session/src/connect/connection_writes.rs b/linux/crates/session/src/connect/connection_writes.rs index 1dc682c451..b5b52f76bb 100644 --- a/linux/crates/session/src/connect/connection_writes.rs +++ b/linux/crates/session/src/connect/connection_writes.rs @@ -1,11 +1,13 @@ use futures::future::BoxFuture; use tablepro_storage::{ConnectionStore, SavedConnection, StorageError}; +use uuid::Uuid; use crate::runtime::Tasks; /// Where a saved connection entry is written. pub trait ConnectionWrites: Send + Sync { fn upsert(&self, connection: SavedConnection) -> BoxFuture<'_, Result<(), StorageError>>; + fn remove(&self, id: Uuid) -> BoxFuture<'_, Result<(), StorageError>>; } /// The connection list on disk, written on the blocking pool because a @@ -34,4 +36,15 @@ impl ConnectionWrites for StoreConnectionWrites { .map_err(|failure| StorageError::Schema(format!("the connections write task failed: {failure}")))? }) } + + fn remove(&self, id: Uuid) -> BoxFuture<'_, Result<(), StorageError>> { + let store = self.store.clone(); + let removed = self.tasks.spawn_blocking_task(move || store.remove_blocking(id)); + Box::pin(async move { + removed + .await + .map_err(|failure| StorageError::Schema(format!("the connections write task failed: {failure}")))? + .map(|_| ()) + }) + } } diff --git a/linux/crates/session/src/connect/persist_pending_save.rs b/linux/crates/session/src/connect/persist_pending_save.rs index a703ec7ddd..690c74c771 100644 --- a/linux/crates/session/src/connect/persist_pending_save.rs +++ b/linux/crates/session/src/connect/persist_pending_save.rs @@ -122,6 +122,11 @@ mod tests { } }) } + + fn remove(&self, id: Uuid) -> BoxFuture<'_, Result<(), StorageError>> { + self.writes.lock().unwrap().retain(|(_, written)| written.id != id); + Box::pin(async { Ok(()) }) + } } fn connection(id: Uuid) -> SavedConnection { From 31ab99f6c441728130598fcb3738c9a3ffc7b6f2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 16:14:20 +0700 Subject: [PATCH 053/178] fix(linux-storage): name an imported copy apart from the saved entry --- linux/crates/storage/src/connections.rs | 48 ++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/linux/crates/storage/src/connections.rs b/linux/crates/storage/src/connections.rs index 32482f8475..918c5ad9b3 100644 --- a/linux/crates/storage/src/connections.rs +++ b/linux/crates/storage/src/connections.rs @@ -346,6 +346,12 @@ impl ConnectionStore { } Some(_) => { connection.id = Uuid::new_v4(); + // Two rows of one name, one of them pointing somewhere + // else, would leave the user guessing which is theirs. + let taken: Vec<&str> = list.iter().map(|saved| saved.name.as_str()).collect(); + if taken.contains(&connection.name.as_str()) { + connection.name = suffixed_name(&connection.name, "imported", &taken); + } list.push(connection); report.added += 1; } @@ -472,7 +478,11 @@ fn read_document(path: &Path) -> Result, DocumentProblem> { pub fn duplicate(source: &SavedConnection, taken: &[SavedConnection]) -> SavedConnection { SavedConnection { id: Uuid::new_v4(), - name: copy_name(&source.name, &taken.iter().map(|c| c.name.as_str()).collect::>()), + name: suffixed_name( + &source.name, + "copy", + &taken.iter().map(|c| c.name.as_str()).collect::>(), + ), // A copy has never been opened, whatever the original did, so // it sorts by name rather than claiming the original's recency. // The colour and the group do carry: a copy points at the same @@ -483,15 +493,15 @@ pub fn duplicate(source: &SavedConnection, taken: &[SavedConnection]) -> SavedCo } /// `name (copy)`, then `name (copy 2)` and on, until one is free. -fn copy_name(name: &str, taken: &[&str]) -> String { - let first = format!("{name} (copy)"); +fn suffixed_name(name: &str, suffix: &str, taken: &[&str]) -> String { + let first = format!("{name} ({suffix})"); if !taken.contains(&first.as_str()) { return first; } // A name is short and the list is a person's own connections, so // counting up from two is bounded by how many copies they made. (2..) - .map(|n| format!("{name} (copy {n})")) + .map(|n| format!("{name} ({suffix} {n})")) .find(|candidate| !taken.contains(&candidate.as_str())) .unwrap_or(first) } @@ -1112,9 +1122,11 @@ mod tests { let kept = list.iter().find(|saved| saved.id == mine.id).expect("the saved entry"); assert_eq!(*kept, mine, "{variant}: the import changed the saved entry"); let imported = list.iter().find(|saved| saved.id != mine.id).expect("the import"); + assert_eq!(imported.name, "production (imported)", "{variant}"); assert_eq!( SavedConnection { id: crafted.id, + name: crafted.name.clone(), ..imported.clone() }, crafted, @@ -1123,6 +1135,34 @@ mod tests { } } + #[test] + fn an_import_added_under_a_new_id_keeps_a_name_nobody_holds() { + let root = TempDir::new().expect("tempdir"); + let store = store(&root); + let mine = saved("production"); + store.upsert_blocking(mine.clone()).expect("upsert"); + let theirs = SavedConnection { + id: mine.id, + host: "staging.internal".to_owned(), + ..saved("staging") + }; + let bytes = crate::document::encode_document(&ConnectionsDocument { + connections: vec![theirs], + }) + .expect("encode"); + + store.import_blocking(Path::new("/tmp/c.json"), &bytes).expect("import"); + + let mut names: Vec = store + .load_blocking() + .expect("load") + .iter() + .map(|saved| saved.name.clone()) + .collect(); + names.sort(); + assert_eq!(names, ["production", "staging"]); + } + #[test] fn an_import_leaves_the_connections_the_file_does_not_mention() { let root = TempDir::new().expect("tempdir"); From df811ca8641f627368efcac19f0ede2a24d4a217 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 16:14:20 +0700 Subject: [PATCH 054/178] docs(linux): drop prompt comments that only restate the code --- linux/crates/app/src/ui/credential_prompt/presenter.rs | 2 -- linux/crates/app/src/ui/credential_prompt/prompt_form.rs | 2 -- 2 files changed, 4 deletions(-) diff --git a/linux/crates/app/src/ui/credential_prompt/presenter.rs b/linux/crates/app/src/ui/credential_prompt/presenter.rs index 16e9190074..3cd0545d29 100644 --- a/linux/crates/app/src/ui/credential_prompt/presenter.rs +++ b/linux/crates/app/src/ui/credential_prompt/presenter.rs @@ -52,8 +52,6 @@ impl CredentialPromptPresenter { } } -/// The response the dialog closes with, or `None` as soon as nobody -/// waits for the answer. async fn response_while_wanted( dialog: &adw::AlertDialog, parent: >k4::Widget, diff --git a/linux/crates/app/src/ui/credential_prompt/prompt_form.rs b/linux/crates/app/src/ui/credential_prompt/prompt_form.rs index 1983963839..5c9e59039d 100644 --- a/linux/crates/app/src/ui/credential_prompt/prompt_form.rs +++ b/linux/crates/app/src/ui/credential_prompt/prompt_form.rs @@ -89,8 +89,6 @@ impl PromptForm { Self { dialog, rows, remember } } - /// Close the dialog with nothing left typed in it, for a prompt - /// nobody waits on any more. pub fn abandon(&self) { for row in &self.rows { drop(row.take()); From b7ec8dc488ce41f00cd667f2ca39b3c2d54c26ae Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 16:14:20 +0700 Subject: [PATCH 055/178] docs(linux): drop changelog lines for fixes to unreleased flows --- linux/CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 30ce1b9c14..4bc7907b7d 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -18,9 +18,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. -- A cancelled connect could still save the connection and its password, and left its password dialog open. -- Closing the window cut the database connection off instead of closing it. - -### Security - -- An imported connection with the id of a saved one could take over its stored passwords. From be04899320a31cac1ad366c6a43a3a1c5625acc1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 16:55:30 +0700 Subject: [PATCH 056/178] build(linux): list the new app files in POTFILES.in --- linux/po/POTFILES.in | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 73b0c02a80..8628960946 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -14,7 +14,9 @@ crates/app/src/runtime/deliver.rs crates/app/src/runtime/gio_process_hold.rs crates/app/src/runtime/mod.rs crates/app/src/services/active_session.rs +crates/app/src/services/cell_overlay.rs crates/app/src/services/change_tracker.rs +crates/app/src/services/change_tracker_registry.rs crates/app/src/services/history_availability.rs crates/app/src/services/history_service.rs crates/app/src/services/mod.rs @@ -23,6 +25,12 @@ crates/app/src/services/secret_save_report.rs crates/app/src/services/session_context.rs crates/app/src/services/single_instance.rs crates/app/src/services/structure_tracker.rs +crates/app/src/services/structure_tracker_handle.rs +crates/app/src/services/structure_tracker_registry.rs +crates/app/src/services/tab_registry.rs +crates/app/src/services/tab_tracker_handle.rs +crates/app/src/services/tracker_delta.rs +crates/app/src/services/tracker_edit.rs crates/app/src/services/workspace_state.rs crates/app/src/storage.rs crates/app/src/test_support.rs @@ -35,6 +43,7 @@ crates/app/src/test_support/unlabelled_widget.rs crates/app/src/test_support/wait_timed_out.rs crates/app/src/test_support/widget_lookup.rs crates/app/src/ui/app/browse.rs +crates/app/src/ui/app/connect_flow.rs crates/app/src/ui/app/connection.rs crates/app/src/ui/app/import.rs crates/app/src/ui/app/mod.rs From 007d3021178507cc57c82510b0fcf96df377997e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:07:05 +0700 Subject: [PATCH 057/178] fix(linux): keep client library statement text out of the logs --- linux/CHANGELOG.md | 1 + linux/Cargo.lock | 2 + linux/crates/app/Cargo.toml | 4 + linux/crates/app/src/logging.rs | 150 +++++++++++++++++++++++++++++--- linux/docs/adding-drivers.md | 1 + linux/docs/error-handling.md | 2 +- 6 files changed, 148 insertions(+), 12 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 7f68f55227..e24958e797 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -42,3 +42,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CSV export did not neutralise formulas in values of a type TablePro does not recognise. - Format could put a space inside `+=`, `b'01'` or `@@version`, or between a MySQL function such as `GROUP_CONCAT` and its parenthesis, and change what the statement does. - Opening a pipe or a device as a SQL file never finished, or read past the 8 MB limit. +- SQL run in the editor, and its values, reached the journal from the database client libraries at debug level. diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 56873a98cb..ab9f7bfd0d 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -4318,6 +4318,7 @@ dependencies = [ "glib-build-tools", "gtk4", "libadwaita", + "log", "relm4", "secrecy", "serde", @@ -4340,6 +4341,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "tracing-log", "tracing-subscriber", "uuid", ] diff --git a/linux/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml index c681eb4e2b..f0f6c3a3cb 100644 --- a/linux/crates/app/Cargo.toml +++ b/linux/crates/app/Cargo.toml @@ -68,6 +68,10 @@ tablepro-session = { path = "../session", features = ["fakes"] } tablepro-test-support = { path = "../test-support" } tokio = { workspace = true, features = ["macros", "rt", "test-util"] } tempfile.workspace = true +# tokio-postgres logs through `log`, so the logging tests drive that +# bridge the way it does. +log = "0.4" +tracing-log = "0.2" [build-dependencies] glib-build-tools.workspace = true diff --git a/linux/crates/app/src/logging.rs b/linux/crates/app/src/logging.rs index a7d65c3434..59d17c705c 100644 --- a/linux/crates/app/src/logging.rs +++ b/linux/crates/app/src/logging.rs @@ -1,10 +1,24 @@ use std::error::Error; use thiserror::Error as ThisError; -use tracing_subscriber::EnvFilter; +use tracing::Subscriber; +use tracing_subscriber::filter::{FilterExt, LevelFilter, Targets}; +use tracing_subscriber::fmt::MakeWriter; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, Layer}; use crate::config::Profile; +/// The client libraries under the drivers, by target prefix. Each can +/// log what a user's database was sent or answered: tokio-postgres the +/// text of every statement at debug, sqlx every statement and a slow +/// one as a warning, tiberius rows at trace and server messages as +/// errors, the clickhouse crate exceptions that quote the query, and +/// deadpool the errors of the connections it recycles. The drivers map +/// and log what they need from these libraries themselves. +const CLIENT_LIBRARIES: &[&str] = &["tokio_postgres", "deadpool", "tiberius", "sqlx", "clickhouse"]; + #[derive(Debug, ThisError)] pub enum LoggingError { #[error("could not install the tracing subscriber: {0}")] @@ -55,16 +69,11 @@ pub fn init(profile: Profile) -> Result<(), LoggingError> { let format = asked.as_deref().and_then(parse_format); let unknown_format = asked.as_deref().is_some_and(|value| parse_format(value).is_none()); - let builder = tracing_subscriber::fmt() - .with_env_filter(filter) - .with_writer(std::io::stderr) - .with_target(false); - match format.unwrap_or(LogFormat::Text) { - // One object per line, so a shipper reads a field rather than a - // position in a sentence. - LogFormat::Json => builder.json().try_init().map_err(LoggingError::Install)?, - LogFormat::Text => builder.try_init().map_err(LoggingError::Install)?, - } + // Installing it also installs the `log` bridge, so the libraries + // that log through `log` pass the same filter. + subscriber(filter, format.unwrap_or(LogFormat::Text), std::io::stderr) + .try_init() + .map_err(|error| LoggingError::Install(Box::new(error)))?; if unparsable { tracing::warn!( @@ -78,10 +87,129 @@ pub fn init(profile: Profile) -> Result<(), LoggingError> { Ok(()) } +/// What `init` installs, writing to `writer`. +/// +/// The client libraries are filtered beside `filter` rather than inside +/// it, so no `RUST_LOG` directive, however specific, turns them back on. +fn subscriber(filter: EnvFilter, format: LogFormat, writer: W) -> impl Subscriber + Send + Sync + 'static +where + W: for<'writer> MakeWriter<'writer> + Send + Sync + 'static, +{ + let output = tracing_subscriber::fmt::layer().with_writer(writer).with_target(false); + let output = match format { + // One object per line, so a shipper reads a field rather than a + // position in a sentence. + LogFormat::Json => output.json().boxed(), + LogFormat::Text => output.boxed(), + }; + tracing_subscriber::registry().with(output.with_filter(filter.and(client_libraries_silenced()))) +} + +fn client_libraries_silenced() -> Targets { + CLIENT_LIBRARIES + .iter() + .fold(Targets::new().with_default(LevelFilter::TRACE), |targets, library| { + targets.with_target(*library, LevelFilter::OFF) + }) +} + #[cfg(test)] mod tests { + use std::io::Write; + use std::sync::{Arc, Mutex, PoisonError}; + use super::*; + const SECRET: &str = "ALTER ROLE app PASSWORD 'hunter2'"; + + #[derive(Clone, Default)] + struct Captured(Arc>>); + + impl Captured { + fn text(&self) -> String { + String::from_utf8_lossy(&self.0.lock().unwrap_or_else(PoisonError::into_inner)).into_owned() + } + } + + impl Write for Captured { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .lock() + .unwrap_or_else(PoisonError::into_inner) + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'writer> MakeWriter<'writer> for Captured { + type Writer = Self; + + fn make_writer(&'writer self) -> Self::Writer { + self.clone() + } + } + + fn written(rust_log: &str, format: LogFormat, emit: impl FnOnce()) -> String { + let captured = Captured::default(); + let subscriber = subscriber(EnvFilter::new(rust_log), format, captured.clone()); + tracing::subscriber::with_default(subscriber, emit); + captured.text() + } + + #[test] + fn client_library_events_are_dropped_whatever_rust_log_asks() { + for format in [LogFormat::Text, LogFormat::Json] { + let text = written( + "trace,tokio_postgres::simple_query=trace,sqlx::query=trace", + format, + || { + tracing::debug!(target: "tokio_postgres::simple_query", "executing simple query: {SECRET}"); + tracing::warn!(target: "sqlx::query", "slow statement: {SECRET}"); + tracing::trace!(target: "sqlx::explain", "{SECRET}"); + tracing::error!(target: "tiberius::tds::stream::token", message = SECRET); + tracing::debug!(target: "clickhouse::error", "{SECRET}"); + tracing::warn!(target: "deadpool.postgres", "Connection error: {SECRET}"); + tracing::debug!(target: "tablepro::ui::editor", "the run ended"); + }, + ); + + assert!(!text.contains("hunter2"), "{format:?}: {text}"); + assert!(text.contains("the run ended"), "{format:?}: {text}"); + } + } + + #[test] + fn statements_logged_through_log_are_dropped() { + // Fails only when a logger other than the bridge is already set, + // which nothing in this binary does. + let _ = tracing_log::LogTracer::init(); + + let text = written("debug", LogFormat::Text, || { + log::debug!(target: "tokio_postgres::simple_query", "executing simple query: {SECRET}"); + log::debug!(target: "tokio_postgres::prepare", "preparing query s0: {SECRET}"); + log::info!(target: "tokio_postgres::connection", "NOTICE: {SECRET}"); + log::debug!(target: "drivers_postgres::pool", "a pooled connection ended"); + }); + + assert!(!text.contains("hunter2"), "{text}"); + assert!(text.contains("a pooled connection ended"), "{text}"); + } + + #[test] + fn the_filter_still_drops_what_rust_log_leaves_out() { + let text = written("info", LogFormat::Text, || { + tracing::debug!(target: "tablepro::ui", "noise"); + tracing::info!(target: "tablepro::ui", "kept"); + }); + + assert!(!text.contains("noise"), "{text}"); + assert!(text.contains("kept"), "{text}"); + } + #[test] fn default_level_per_profile() { assert_eq!(default_level(Profile::Development), "debug"); diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index 4c4a95f034..12777801e0 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -376,6 +376,7 @@ cargo nextest run --locked --profile ci -p tablepro-driver- --run-ignore - [ ] Saves record every step in a `WriteLedger`, and the stop is held while a commit is in flight. - [ ] Editor sessions are one connection per tab, one run at a time, with `ResultSink` backpressure and the row-limit rule. - [ ] Errors map to `DriverError` by the engine's code ([error-handling.md](error-handling.md)). +- [ ] The client library's log target is in `CLIENT_LIBRARIES` in `crates/app/src/logging.rs`, so no statement text reaches the journal ([error-handling.md](error-handling.md#logging)). - [ ] Registered in `build_registry`; added to the workspace and to `crates/app/Cargo.toml`. - [ ] Both conformance suites run; the CI matrix and nextest groups include the package. - [ ] A page in `docs/drivers/` covers timeouts, stops, read-only, writes, trust and the engine's limits. diff --git a/linux/docs/error-handling.md b/linux/docs/error-handling.md index ae18e8e053..425cc5fd9a 100644 --- a/linux/docs/error-handling.md +++ b/linux/docs/error-handling.md @@ -154,7 +154,7 @@ Levels: `error!` for what the user must see or a broken contract, `warn!` for re Never log a password, a secret, a key or a bound value. sqlx's own statement logging is turned off in the MySQL and SQLite drivers, and a test in each crate (`tests/statement_logging.rs`) proves no statement text reaches the subscriber. -tokio-postgres is not filtered yet. It logs the text of every simple query, which is all editor SQL, through `log` at `debug`, and `tracing-subscriber` forwards `log` records to the same output. A development build logs at `debug` by default, so an `ALTER ROLE ... PASSWORD '...'` run in the editor reaches the journal. Until the default filter covers it, run with `RUST_LOG=debug,tokio_postgres=info`. +The client libraries under the drivers log what a database was sent or answered: tokio-postgres the text of every statement at `debug`, through `log`, which `tracing-subscriber` forwards; sqlx every statement, and a slow one as a warning; tiberius rows at `trace` and server messages as errors; the clickhouse crate exceptions that quote the query; deadpool the errors of the connections it recycles. So the subscriber drops every event whose target starts with `tokio_postgres`, `deadpool`, `tiberius`, `sqlx` or `clickhouse`, at every level. That filter sits beside the `RUST_LOG` one, so no directive turns them back on, however specific. A driver logs what it needs from their errors itself. A new driver's client library joins `CLIENT_LIBRARIES` in `logging.rs`. ## `unwrap`, `expect` and `panic!` From 7e476a3ecc983ece6d374f6c060c83c982a80cfe Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:07:29 +0700 Subject: [PATCH 058/178] fix(linux): stop a MySQL, SQL Server or SQLite run whose caller drops --- .../drivers/mssql/src/editor_session.rs | 184 +++++++++++------- .../drivers/mysql/src/editor_session.rs | 182 ++++++++++------- .../drivers/sqlite/src/editor_session.rs | 165 ++++++++++------ .../src/conformance/cases/cancellation.rs | 138 ++++++++++++- .../src/conformance/connection_suite_macro.rs | 20 +- linux/docs/adding-drivers.md | 4 +- linux/docs/testing.md | 3 +- 7 files changed, 491 insertions(+), 205 deletions(-) diff --git a/linux/crates/drivers/mssql/src/editor_session.rs b/linux/crates/drivers/mssql/src/editor_session.rs index 0f02fc76c0..4ec3c2850e 100644 --- a/linux/crates/drivers/mssql/src/editor_session.rs +++ b/linux/crates/drivers/mssql/src/editor_session.rs @@ -7,6 +7,7 @@ use tablepro_core::{ CallOptions, DriverError, EditorSession, EditorSessionId, EditorSessionOptions, EditorSessionState, LossPhase, ResultSink, RunEnd, RunOptions, RunSummary, StopReason, StopRequest, TimeoutPhase, TransactionState, UserSql, }; +use tokio::sync::OwnedMutexGuard; use tokio::time::Instant; use tokio_util::sync::CancellationToken; @@ -24,7 +25,15 @@ use crate::physical::MssqlLink; /// step with the server ends the session instead. pub(crate) struct MssqlEditorSession { id: EditorSessionId, - link: tokio::sync::Mutex>, + shared: Arc, +} + +/// What a run needs once its caller is gone. A run goes on a task of +/// its own, so a dropped caller is a cancel that still sends the +/// attention and settles the session, rather than a statement left +/// running for nobody. +struct Shared { + link: Arc>>, running: AtomicBool, lost: AtomicBool, /// The running statement's stop, so closing the tab stops it. @@ -45,16 +54,85 @@ impl MssqlEditorSession { let liveness = endpoint.liveness(); Ok(Arc::new(Self { id: EditorSessionId::new(), - link: tokio::sync::Mutex::new(Some(link)), - running: AtomicBool::new(false), - lost: AtomicBool::new(false), - current: Mutex::new(None), - statement_timeout: options.statement_timeout, - policy: StopPolicy::within(liveness.cancel_grace), - cancel_grace: liveness.cancel_grace, - close_timeout: liveness.close_timeout, + shared: Arc::new(Shared { + link: Arc::new(tokio::sync::Mutex::new(Some(link))), + running: AtomicBool::new(false), + lost: AtomicBool::new(false), + current: Mutex::new(None), + statement_timeout: options.statement_timeout, + policy: StopPolicy::within(liveness.cancel_grace), + cancel_grace: liveness.cancel_grace, + close_timeout: liveness.close_timeout, + }), })) } +} + +impl Shared { + fn state(&self) -> EditorSessionState { + if self.lost.load(Ordering::SeqCst) { + EditorSessionState::Lost + } else if self.running.load(Ordering::SeqCst) { + EditorSessionState::Running + } else { + EditorSessionState::Ready + } + } + + /// One run on the connection `held` holds, which it gives up when + /// the connection cannot take another. + async fn run_held( + &self, + mut held: OwnedMutexGuard>, + sql: UserSql, + options: RunOptions, + mut sink: Box, + ) -> Result { + // A run whose task died leaves its connection behind, in a state + // nothing knows. + let Some(link) = held.as_mut().filter(|_| !self.lost.load(Ordering::SeqCst)) else { + return Err(DriverError::ConnectionLost { + during: LossPhase::Idle, + }); + }; + let started = Instant::now(); + self.running.store(true, Ordering::SeqCst); + let request = StopRequest::new(); + self.set_current(Some(request.clone())); + + let supervised = self.supervise(link, &request, &sql, &options, sink.as_mut()).await; + let (end, transaction_state, keep) = match supervised { + Some(Ok(outcome)) if outcome.reusable => { + let (state, keep) = self.state_after_run(link).await; + (Ok(outcome.end), state, keep) + } + // The run ended, but somewhere the connection cannot go on + // from: the session ends with it. + Some(Ok(outcome)) => (Ok(outcome.end), TransactionState::Unknown, false), + Some(Err(error)) => (Err(error), TransactionState::Unknown, false), + None => ( + Ok(match request.reason() { + Some(StopReason::DeadlineElapsed) => RunEnd::TimedOut { + server_cancelled: false, + }, + _ => RunEnd::Cancelled, + }), + TransactionState::Unknown, + false, + ), + }; + self.set_current(None); + self.running.store(false, Ordering::SeqCst); + if !keep { + self.lost.store(true, Ordering::SeqCst); + *held = None; + } + Ok(RunSummary { + elapsed: started.elapsed(), + end: end?, + transaction_state, + }) + } fn set_current(&self, request: Option) { *self.current.lock().unwrap_or_else(PoisonError::into_inner) = request; @@ -121,76 +199,43 @@ impl EditorSession for MssqlEditorSession { } fn state(&self) -> EditorSessionState { - if self.lost.load(Ordering::SeqCst) { - EditorSessionState::Lost - } else if self.running.load(Ordering::SeqCst) { - EditorSessionState::Running - } else { - EditorSessionState::Ready - } + self.shared.state() } async fn run( &self, sql: UserSql, options: RunOptions, - mut sink: Box, + sink: Box, ) -> Result { - let Ok(mut held) = self.link.try_lock() else { + let Ok(held) = self.shared.link.clone().try_lock_owned() else { return Err(DriverError::EditorSessionBusy); }; - let Some(link) = held.as_mut() else { - return Err(DriverError::ConnectionLost { - during: LossPhase::Idle, - }); - }; - let started = Instant::now(); - self.running.store(true, Ordering::SeqCst); - let request = StopRequest::new(); - self.set_current(Some(request.clone())); - - let supervised = self.supervise(link, &request, &sql, &options, sink.as_mut()).await; - let (end, transaction_state, keep) = match supervised { - Some(Ok(outcome)) if outcome.reusable => { - let (state, keep) = self.state_after_run(link).await; - (Ok(outcome.end), state, keep) - } - // The run ended, but somewhere the connection cannot go on - // from: the session ends with it. - Some(Ok(outcome)) => (Ok(outcome.end), TransactionState::Unknown, false), - Some(Err(error)) => (Err(error), TransactionState::Unknown, false), - None => ( - Ok(match request.reason() { - Some(StopReason::DeadlineElapsed) => RunEnd::TimedOut { - server_cancelled: false, - }, - _ => RunEnd::Cancelled, - }), - TransactionState::Unknown, - false, - ), - }; - self.set_current(None); - self.running.store(false, Ordering::SeqCst); - if !keep { - self.lost.store(true, Ordering::SeqCst); - *held = None; - } - Ok(RunSummary { - elapsed: started.elapsed(), - end: end?, - transaction_state, - }) + // Dropping the caller is itself a cancel: the statement is + // already running on the server and has to be stopped. + let cancel_on_drop = options.cancel.clone().drop_guard(); + let shared = self.shared.clone(); + let running = tokio::spawn(async move { shared.run_held(held, sql, options, sink).await }); + let outcome = running.await.unwrap_or_else(|_| { + self.shared.running.store(false, Ordering::SeqCst); + self.shared.lost.store(true, Ordering::SeqCst); + Err(DriverError::ConnectionLost { + during: LossPhase::Statement, + }) + }); + drop(cancel_on_drop.disarm()); + outcome } async fn transaction_state(&self, options: CallOptions) -> Result { + let shared = &self.shared; let mut held = tokio::select! { biased; () = options.cancel().cancelled() => return Err(DriverError::Cancelled), () = elapsed(options.deadline()) => { return Err(DriverError::Timeout { phase: TimeoutPhase::Statement, server_cancelled: false }); } - held = self.link.lock() => held, + held = shared.link.lock() => held, }; let Some(link) = held.as_mut() else { return Err(DriverError::ConnectionLost { @@ -208,7 +253,7 @@ impl EditorSession for MssqlEditorSession { Some(Ok(state)) => Ok(state), Some(Err(error)) => { if ends_connection(&error) { - self.lost.store(true, Ordering::SeqCst); + shared.lost.store(true, Ordering::SeqCst); *held = None; } Err(error) @@ -216,7 +261,7 @@ impl EditorSession for MssqlEditorSession { // The question was cut off mid-request, so the connection is // out of step and the session cannot go on. None => { - self.lost.store(true, Ordering::SeqCst); + shared.lost.store(true, Ordering::SeqCst); *held = None; Err(match options.cancel().is_cancelled() { true => DriverError::Cancelled, @@ -230,17 +275,18 @@ impl EditorSession for MssqlEditorSession { } async fn close(&self) { - self.lost.store(true, Ordering::SeqCst); - if let Some(request) = self.current.lock().unwrap_or_else(PoisonError::into_inner).take() { + let shared = &self.shared; + shared.lost.store(true, Ordering::SeqCst); + if let Some(request) = shared.current.lock().unwrap_or_else(PoisonError::into_inner).take() { request.request(StopReason::Shutdown); } - // A run still stopping holds the connection; it closes when the - // session itself is dropped. - let Ok(mut held) = tokio::time::timeout(self.cancel_grace, self.link.lock()).await else { + // A run still stopping holds the connection until it settles or + // gives the connection up at the end of the cancel grace. + let Ok(mut held) = tokio::time::timeout(shared.cancel_grace, shared.link.lock()).await else { return; }; if let Some(link) = held.take() { - let _ = tokio::time::timeout(self.close_timeout, link.client.close()).await; + let _ = tokio::time::timeout(shared.close_timeout, link.client.close()).await; } } } diff --git a/linux/crates/drivers/mysql/src/editor_session.rs b/linux/crates/drivers/mysql/src/editor_session.rs index 5a0150a5e0..10bdd7cdd2 100644 --- a/linux/crates/drivers/mysql/src/editor_session.rs +++ b/linux/crates/drivers/mysql/src/editor_session.rs @@ -9,6 +9,7 @@ use tablepro_core::{ CallOptions, DriverError, EditorSession, EditorSessionId, EditorSessionOptions, EditorSessionState, LivenessPolicy, LossPhase, ResultSink, RowLimit, RunEnd, RunOptions, RunSummary, TimeoutPhase, TransactionState, UserSql, }; +use tokio::sync::OwnedMutexGuard; use tokio::time::Instant; use tokio_util::sync::CancellationToken; @@ -33,7 +34,15 @@ struct Held { /// read-only default, the lock bounds and the row limit are its own. pub(crate) struct MysqlEditorSession { id: EditorSessionId, - held: tokio::sync::Mutex>, + shared: Arc, +} + +/// What a run needs once its caller is gone. A run goes on a task of +/// its own, so a dropped caller is a cancel that still sends the KILL +/// and settles the session, rather than a statement left running for +/// nobody. +struct Shared { + held: Arc>>, connection_id: u64, flavour: MySqlFlavour, control: ControlLink, @@ -67,20 +76,83 @@ impl MysqlEditorSession { let liveness = *endpoint.liveness(); Ok(Arc::new(Self { id: EditorSessionId::new(), - held: tokio::sync::Mutex::new(Some(Held { - connection: physical.connection, - select_limit: None, - })), - connection_id: physical.connection_id, - flavour: physical.flavour, - control: ControlLink::new(endpoint), - probe: TransactionProbe::new(physical.flavour), - running: AtomicBool::new(false), - lost: AtomicBool::new(false), - closing: CancellationToken::new(), - liveness, + shared: Arc::new(Shared { + held: Arc::new(tokio::sync::Mutex::new(Some(Held { + connection: physical.connection, + select_limit: None, + }))), + connection_id: physical.connection_id, + flavour: physical.flavour, + control: ControlLink::new(endpoint), + probe: TransactionProbe::new(physical.flavour), + running: AtomicBool::new(false), + lost: AtomicBool::new(false), + closing: CancellationToken::new(), + liveness, + }), })) } +} + +impl Shared { + fn state(&self) -> EditorSessionState { + if self.lost.load(Ordering::SeqCst) { + EditorSessionState::Lost + } else if self.running.load(Ordering::SeqCst) { + EditorSessionState::Running + } else { + EditorSessionState::Ready + } + } + + /// One run on the connection `guard` holds, which it gives up when + /// the connection cannot take another. + async fn run_held( + &self, + mut guard: OwnedMutexGuard>, + sql: UserSql, + options: RunOptions, + mut sink: Box, + ) -> Result { + // A run whose task died leaves its connection behind, in a state + // nothing knows. + let Some(held) = guard.as_mut().filter(|_| !self.lost.load(Ordering::SeqCst)) else { + return Err(DriverError::ConnectionLost { + during: LossPhase::Idle, + }); + }; + let started = Instant::now(); + self.running.store(true, Ordering::SeqCst); + let supervised = self.supervise(held, &sql, &options, sink.as_mut()).await; + let transaction_state = match &supervised { + Ok((_, true)) => { + let asking = self.probe.state(&mut held.connection); + tokio::time::timeout(self.liveness.cancel_grace, asking) + .await + .unwrap_or(TransactionState::Unknown) + } + _ => TransactionState::Unknown, + }; + self.running.store(false, Ordering::SeqCst); + let end = match supervised { + Ok((end, true)) => end, + Ok((end, false)) => { + self.lost.store(true, Ordering::SeqCst); + *guard = None; + end + } + Err(error) => { + self.lost.store(true, Ordering::SeqCst); + *guard = None; + return Err(error); + } + }; + Ok(RunSummary { + elapsed: started.elapsed(), + end, + transaction_state, + }) + } /// Run the script, stopping it on a cancel, at the deadline, when /// the session closes or when the sink stops reading. A stop is KILL @@ -179,67 +251,40 @@ impl EditorSession for MysqlEditorSession { } fn state(&self) -> EditorSessionState { - if self.lost.load(Ordering::SeqCst) { - EditorSessionState::Lost - } else if self.running.load(Ordering::SeqCst) { - EditorSessionState::Running - } else { - EditorSessionState::Ready - } + self.shared.state() } async fn run( &self, sql: UserSql, options: RunOptions, - mut sink: Box, + sink: Box, ) -> Result { - let Ok(mut guard) = self.held.try_lock() else { + let Ok(guard) = self.shared.held.clone().try_lock_owned() else { return Err(DriverError::EditorSessionBusy); }; - let Some(held) = guard.as_mut() else { - return Err(DriverError::ConnectionLost { - during: LossPhase::Idle, - }); - }; - let started = Instant::now(); - self.running.store(true, Ordering::SeqCst); - let supervised = self.supervise(held, &sql, &options, sink.as_mut()).await; - let transaction_state = match &supervised { - Ok((_, true)) => { - let asking = self.probe.state(&mut held.connection); - tokio::time::timeout(self.liveness.cancel_grace, asking) - .await - .unwrap_or(TransactionState::Unknown) - } - _ => TransactionState::Unknown, - }; - self.running.store(false, Ordering::SeqCst); - let end = match supervised { - Ok((end, true)) => end, - Ok((end, false)) => { - self.lost.store(true, Ordering::SeqCst); - *guard = None; - end - } - Err(error) => { - self.lost.store(true, Ordering::SeqCst); - *guard = None; - return Err(error); - } - }; - Ok(RunSummary { - elapsed: started.elapsed(), - end, - transaction_state, - }) + // Dropping the caller is itself a cancel: the statement is + // already running on the server and has to be stopped. + let cancel_on_drop = options.cancel.clone().drop_guard(); + let shared = self.shared.clone(); + let running = tokio::spawn(async move { shared.run_held(guard, sql, options, sink).await }); + let outcome = running.await.unwrap_or_else(|_| { + self.shared.running.store(false, Ordering::SeqCst); + self.shared.lost.store(true, Ordering::SeqCst); + Err(DriverError::ConnectionLost { + during: LossPhase::Statement, + }) + }); + drop(cancel_on_drop.disarm()); + outcome } async fn transaction_state(&self, options: CallOptions) -> Result { + let shared = &self.shared; let asking = async { - let mut guard = self.held.lock().await; + let mut guard = shared.held.lock().await; match guard.as_mut() { - Some(held) => Ok(self.probe.state(&mut held.connection).await), + Some(held) => Ok(shared.probe.state(&mut held.connection).await), None => Err(DriverError::ConnectionLost { during: LossPhase::Idle, }), @@ -257,16 +302,17 @@ impl EditorSession for MysqlEditorSession { } async fn close(&self) { - self.lost.store(true, Ordering::SeqCst); - self.closing.cancel(); - // A statement that will not stop keeps the connection, which - // closes when the session itself is dropped. - let grace = self.liveness.cancel_grace + Duration::from_millis(100); - if let Ok(mut guard) = tokio::time::timeout(grace, self.held.lock()).await + let shared = &self.shared; + shared.lost.store(true, Ordering::SeqCst); + shared.closing.cancel(); + // A statement that will not stop keeps the connection until its + // run gives it up at the end of the cancel grace. + let grace = shared.liveness.cancel_grace + Duration::from_millis(100); + if let Ok(mut guard) = tokio::time::timeout(grace, shared.held.lock()).await && let Some(held) = guard.take() { - let _ = tokio::time::timeout(self.liveness.close_timeout, held.connection.close()).await; + let _ = tokio::time::timeout(shared.liveness.close_timeout, held.connection.close()).await; } - self.control.close().await; + shared.control.close().await; } } diff --git a/linux/crates/drivers/sqlite/src/editor_session.rs b/linux/crates/drivers/sqlite/src/editor_session.rs index f68b9423f3..ad5cd2787c 100644 --- a/linux/crates/drivers/sqlite/src/editor_session.rs +++ b/linux/crates/drivers/sqlite/src/editor_session.rs @@ -1,6 +1,6 @@ use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Mutex, PoisonError}; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use async_trait::async_trait; @@ -10,6 +10,7 @@ use tablepro_core::{ CallOptions, DriverError, EditorSession, EditorSessionId, EditorSessionOptions, EditorSessionState, LivenessPolicy, LossPhase, ResultSink, RunEnd, RunOptions, RunSummary, TimeoutPhase, TransactionState, UserSql, }; +use tokio::sync::OwnedMutexGuard; use tokio::time::Instant; use tokio_util::sync::CancellationToken; @@ -27,7 +28,16 @@ use crate::result_stream::{StreamOutcome, run_script}; #[derive(Debug)] pub(crate) struct SqliteEditorSession { id: EditorSessionId, - connection: tokio::sync::Mutex>, + shared: Arc, +} + +/// What a run needs once its caller is gone. A run goes on a task of +/// its own, so a dropped caller is a cancel that still raises the +/// interrupt and settles the session, rather than a statement left +/// stepping for nobody. +#[derive(Debug)] +struct Shared { + connection: Arc>>, running: AtomicBool, lost: AtomicBool, /// The running statement's stop, so closing the tab stops it. @@ -70,14 +80,74 @@ impl SqliteEditorSession { }; Ok(Self { id: EditorSessionId::new(), - connection: tokio::sync::Mutex::new(Some(connection)), - running: AtomicBool::new(false), - lost: AtomicBool::new(false), - current: Mutex::new(None), - statement_timeout: session.statement_timeout, - lock_timeout, - cancel_grace: liveness.cancel_grace, - close_timeout: liveness.close_timeout, + shared: Arc::new(Shared { + connection: Arc::new(tokio::sync::Mutex::new(Some(connection))), + running: AtomicBool::new(false), + lost: AtomicBool::new(false), + current: Mutex::new(None), + statement_timeout: session.statement_timeout, + lock_timeout, + cancel_grace: liveness.cancel_grace, + close_timeout: liveness.close_timeout, + }), + }) + } +} + +impl Shared { + fn state(&self) -> EditorSessionState { + if self.lost.load(Ordering::SeqCst) { + EditorSessionState::Lost + } else if self.running.load(Ordering::SeqCst) { + EditorSessionState::Running + } else { + EditorSessionState::Ready + } + } + + /// One run on the connection `held` holds, which it gives up when + /// the connection cannot take another. + async fn run_held( + &self, + mut held: OwnedMutexGuard>, + sql: UserSql, + options: RunOptions, + mut sink: Box, + ) -> Result { + // A run whose task died leaves its connection behind, in a state + // nothing knows. + let Some(connection) = held.as_mut().filter(|_| !self.lost.load(Ordering::SeqCst)) else { + return Err(DriverError::ConnectionLost { + during: LossPhase::Idle, + }); + }; + let started = Instant::now(); + self.running.store(true, Ordering::SeqCst); + let flag = InterruptFlag::new(); + self.set_current(Some(flag.clone())); + + let ended = match flag.install(connection, self.lock_timeout).await { + Ok(()) => self.supervise(connection, &flag, &sql, &options, sink.as_mut()).await, + Err(error) => session_failure(error), + }; + let transaction_state = match &ended { + Ok(_) => self.state_after_run(connection).await, + Err(_) => TransactionState::Unknown, + }; + self.set_current(None); + self.running.store(false, Ordering::SeqCst); + let end = match ended { + Ok(end) => end, + Err(error) => { + self.lost.store(true, Ordering::SeqCst); + *held = None; + return Err(error); + } + }; + Ok(RunSummary { + elapsed: started.elapsed(), + end, + transaction_state, }) } @@ -169,67 +239,43 @@ impl EditorSession for SqliteEditorSession { } fn state(&self) -> EditorSessionState { - if self.lost.load(Ordering::SeqCst) { - EditorSessionState::Lost - } else if self.running.load(Ordering::SeqCst) { - EditorSessionState::Running - } else { - EditorSessionState::Ready - } + self.shared.state() } async fn run( &self, sql: UserSql, options: RunOptions, - mut sink: Box, + sink: Box, ) -> Result { - let Ok(mut held) = self.connection.try_lock() else { + let Ok(held) = self.shared.connection.clone().try_lock_owned() else { return Err(DriverError::EditorSessionBusy); }; - let Some(connection) = held.as_mut() else { - return Err(DriverError::ConnectionLost { - during: LossPhase::Idle, - }); - }; - let started = Instant::now(); - self.running.store(true, Ordering::SeqCst); - let flag = InterruptFlag::new(); - self.set_current(Some(flag.clone())); - - let ended = match flag.install(connection, self.lock_timeout).await { - Ok(()) => self.supervise(connection, &flag, &sql, &options, sink.as_mut()).await, - Err(error) => session_failure(error), - }; - let transaction_state = match &ended { - Ok(_) => self.state_after_run(connection).await, - Err(_) => TransactionState::Unknown, - }; - self.set_current(None); - self.running.store(false, Ordering::SeqCst); - let end = match ended { - Ok(end) => end, - Err(error) => { - self.lost.store(true, Ordering::SeqCst); - *held = None; - return Err(error); - } - }; - Ok(RunSummary { - elapsed: started.elapsed(), - end, - transaction_state, - }) + // Dropping the caller is itself a cancel: the statement keeps + // stepping on the connection's worker until it is interrupted. + let cancel_on_drop = options.cancel.clone().drop_guard(); + let shared = self.shared.clone(); + let running = tokio::spawn(async move { shared.run_held(held, sql, options, sink).await }); + let outcome = running.await.unwrap_or_else(|_| { + self.shared.running.store(false, Ordering::SeqCst); + self.shared.lost.store(true, Ordering::SeqCst); + Err(DriverError::ConnectionLost { + during: LossPhase::Statement, + }) + }); + drop(cancel_on_drop.disarm()); + outcome } async fn transaction_state(&self, options: CallOptions) -> Result { + let shared = &self.shared; let mut held = tokio::select! { biased; () = options.cancel().cancelled() => return Err(DriverError::Cancelled), () = deadline::elapsed(options.deadline()) => { return Err(DriverError::Timeout { phase: TimeoutPhase::Statement, server_cancelled: false }); } - held = self.connection.lock() => held, + held = shared.connection.lock() => held, }; let Some(connection) = held.as_mut() else { return Err(DriverError::ConnectionLost { @@ -244,17 +290,18 @@ impl EditorSession for SqliteEditorSession { } async fn close(&self) { - self.lost.store(true, Ordering::SeqCst); - if let Some(flag) = self.current.lock().unwrap_or_else(PoisonError::into_inner).take() { + let shared = &self.shared; + shared.lost.store(true, Ordering::SeqCst); + if let Some(flag) = shared.current.lock().unwrap_or_else(PoisonError::into_inner).take() { flag.raise(); } - // A statement that will not stop keeps the connection, which - // closes when the session itself is dropped. - let Ok(mut held) = tokio::time::timeout(self.cancel_grace, self.connection.lock()).await else { + // A statement that will not stop keeps the connection until its + // run gives it up at the end of the cancel grace. + let Ok(mut held) = tokio::time::timeout(shared.cancel_grace, shared.connection.lock()).await else { return; }; if let Some(connection) = held.take() { - let _ = tokio::time::timeout(self.close_timeout, connection.close()).await; + let _ = tokio::time::timeout(shared.close_timeout, connection.close()).await; } } } diff --git a/linux/crates/test-support/src/conformance/cases/cancellation.rs b/linux/crates/test-support/src/conformance/cases/cancellation.rs index e95cb33a0e..157393807f 100644 --- a/linux/crates/test-support/src/conformance/cases/cancellation.rs +++ b/linux/crates/test-support/src/conformance/cases/cancellation.rs @@ -1,17 +1,21 @@ use std::time::Duration; -use tablepro_core::{EditorSessionOptions, EditorSessionState, RowLimit, RunEnd, RunOptions}; +use tablepro_core::{ + DriverError, EditorSession, EditorSessionOptions, EditorSessionState, RowLimit, RunEnd, RunOptions, +}; use tokio::time::Instant; use tokio_util::sync::CancellationToken; use crate::conformance::support::{ - SCHEDULING_SLACK, connect, expect, no_deadline, run_completed, run_editor, run_options, statement, unexpected, - unique, wait_for_running, writable, + CALL_DEADLINE, SCHEDULING_SLACK, connect, expect, no_deadline, run_completed, run_editor, run_options, statement, + unexpected, unique, wait_for_running, writable, }; use crate::conformance::{ConformanceEngine, ConformanceFailure, NetworkConformanceEngine, test_liveness}; const EDITOR_CASE: &str = "editor_cancel_reports_cancelled_and_session_stays_ready"; const DROPPED_CASE: &str = "dropped_caller_stops_server_statement"; +const DROPPED_EDITOR_CASE: &str = "dropped_editor_run_stops_server_statement"; +const DROPPED_FILE_EDITOR_CASE: &str = "dropped_editor_run_frees_the_session"; /// Long enough for the statement to reach the server before the case /// stops it, so the stop is tested rather than a race with sending. @@ -107,3 +111,131 @@ pub async fn dropped_caller_stops_server_statement( connection.close().await?; Ok(()) } + +/// Case 4b: dropping the caller of an editor run stops its statement on +/// the server too, and the session then takes the next run or reports +/// itself lost, rather than staying busy for nobody. +pub async fn dropped_editor_run_stops_server_statement( + engine: E, +) -> Result<(), ConformanceFailure> { + let bound = test_liveness().cancel_grace + Duration::from_secs(1); + let connection = connect(DROPPED_EDITOR_CASE, &engine, writable()).await?; + let monitor = connection + .open_editor_session(EditorSessionOptions::default(), CancellationToken::new()) + .await?; + let session = connection + .open_editor_session(EditorSessionOptions::default(), CancellationToken::new()) + .await?; + let marker = unique("dropped_editor"); + let count_sql = engine.running_statement_count_sql(&marker); + let unbounded = engine.unbounded_statement_sql(&marker); + + { + let run = run_editor(session.as_ref(), &unbounded, run_options()); + let seen = wait_for_running( + DROPPED_EDITOR_CASE, + monitor.as_ref(), + &count_sql, + |running| running >= 1, + SEEN_LIMIT, + ); + tokio::select! { + outcome = run => { + return Err(unexpected( + DROPPED_EDITOR_CASE, + format!("the unbounded run finished on its own: {outcome:?}"), + )); + } + seen = seen => { + seen?; + } + } + } + wait_for_running( + DROPPED_EDITOR_CASE, + monitor.as_ref(), + &count_sql, + |running| running == 0, + bound, + ) + .await?; + let ready = settled_after_drop(DROPPED_EDITOR_CASE, session.as_ref(), bound).await?; + if !ready { + let refused = run_editor(session.as_ref(), "SELECT 1", run_options()).await; + expect( + DROPPED_EDITOR_CASE, + matches!(refused, Err(DriverError::ConnectionLost { .. })), + || format!("a lost session answered a run with {refused:?}"), + )?; + } + + session.close().await; + monitor.close().await; + connection.close().await?; + Ok(()) +} + +/// Case 4b for an engine without a server: with nothing to ask what is +/// still running, the next run on the same connection is the proof that +/// the dropped one stopped. +pub async fn dropped_editor_run_frees_the_session(engine: E) -> Result<(), ConformanceFailure> { + let bound = test_liveness().cancel_grace + SCHEDULING_SLACK; + let connection = connect(DROPPED_FILE_EDITOR_CASE, &engine, writable()).await?; + let session = connection + .open_editor_session(EditorSessionOptions::default(), CancellationToken::new()) + .await?; + let unbounded = engine.unbounded_statement_sql(&unique("dropped_editor")); + + { + let run = run_editor(session.as_ref(), &unbounded, run_options()); + tokio::select! { + outcome = run => { + return Err(unexpected( + DROPPED_FILE_EDITOR_CASE, + format!("the unbounded run finished on its own: {outcome:?}"), + )); + } + () = tokio::time::sleep(SETTLE) => {} + } + } + let ready = settled_after_drop(DROPPED_FILE_EDITOR_CASE, session.as_ref(), bound).await?; + expect(DROPPED_FILE_EDITOR_CASE, ready, || { + "the session was lost to a dropped run".to_owned() + })?; + + session.close().await; + connection.close().await?; + Ok(()) +} + +/// Wait out a dropped run: within `bound` the session must stop saying +/// `Running`. A ready session must then complete a run, which it cannot +/// while the dropped statement still holds its connection. Says whether +/// the session was ready rather than lost. +async fn settled_after_drop( + case: &'static str, + session: &dyn EditorSession, + bound: Duration, +) -> Result { + let give_up = Instant::now() + bound; + while session.state() == EditorSessionState::Running { + if Instant::now() >= give_up { + return Err(unexpected( + case, + format!("the session still says Running {bound:?} after its run was dropped"), + )); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + if session.state() == EditorSessionState::Lost { + return Ok(false); + } + let next = run_options().with_deadline(Some(CALL_DEADLINE)); + let started = Instant::now(); + run_completed(case, session, "SELECT 1", next).await?; + let took = started.elapsed(); + expect(case, took <= bound, || { + format!("the next run took {took:?}, so the dropped statement still held the connection") + })?; + Ok(true) +} diff --git a/linux/crates/test-support/src/conformance/connection_suite_macro.rs b/linux/crates/test-support/src/conformance/connection_suite_macro.rs index 94e6ab33b3..f0029cb1a6 100644 --- a/linux/crates/test-support/src/conformance/connection_suite_macro.rs +++ b/linux/crates/test-support/src/conformance/connection_suite_macro.rs @@ -5,8 +5,9 @@ /// that passes without checking anything, and a flag that is on requires /// the matching engine trait: /// -/// - `endpoint: network` adds the dropped-caller and lost-transport -/// cases and needs `NetworkConformanceEngine`; `file` adds neither. +/// - `endpoint: network` adds the dropped-caller, dropped-editor-run and +/// lost-transport cases and needs `NetworkConformanceEngine`; `file` +/// adds a dropped-editor-run case that checks the session instead. /// - `pool: pooled` adds the busy-pool probe and needs /// `PooledConformanceEngine`; `unbounded` is an engine with no slot /// limit. @@ -289,6 +290,12 @@ macro_rules! __conformance_endpoint_cases { dropped_caller_stops_server_statement, $crate::conformance::cases::cancellation::dropped_caller_stops_server_statement::<$engine> } + $crate::__conformance_case! { + $docker, + $engine as ConformanceEngine, + dropped_editor_run_stops_server_statement, + $crate::conformance::cases::cancellation::dropped_editor_run_stops_server_statement::<$engine> + } $crate::__conformance_case! { $docker, $engine as ConformanceEngine, @@ -296,7 +303,14 @@ macro_rules! __conformance_endpoint_cases { $crate::conformance::cases::liveness::lost_transport_reports_connection_lost::<$engine> } }; - (file, $docker:tt, $engine:ty) => {}; + (file, $docker:tt, $engine:ty) => { + $crate::__conformance_case! { + $docker, + $engine as ConformanceEngine, + dropped_editor_run_frees_the_session, + $crate::conformance::cases::cancellation::dropped_editor_run_frees_the_session::<$engine> + } + }; } #[doc(hidden)] diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index 12777801e0..efc7f3fa99 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -273,7 +273,7 @@ An editor tab holds one session for as long as it is open, because the state a u - It is never pooled and never reopened behind the user's back. A lost connection is `EditorSessionState::Lost`, and the tab opens a new session on its next run. - One run at a time: a second `run` fails with `EditorSessionBusy`. - `run(sql, options, sink)` takes `UserSql` as typed and `RunOptions`: `row_limit`, `deadline` and `cancel`. A caller stops a run through `RunOptions::cancel`. -- Only PostgreSQL and ClickHouse arm a drop guard on that token, so a dropped caller is a cancel there. MySQL, SQL Server and SQLite await the run inline: a dropped caller sends no `KILL`, attention or interrupt, and the session reports `Running` from then on. A new driver arms the guard, as `PgEditorSession::run` does. +- A dropped caller is a cancel. The run goes on a task of its own, or an actor's, and `run` arms `options.cancel.clone().drop_guard()` until the outcome arrives, so the stop still goes out and the session settles to `Ready` or `Lost`. `MysqlEditorSession::run` is the shape to copy. - The app's editor splits a script with the engine's grammar and sends one statement per run, or one `GO` batch on SQL Server. A driver still runs whatever text it is given, a whole script included. - A statement the server refused ends the run with `RunEnd::Failed` in the `RunSummary`; an `Err` from `run` means the session itself failed. - `RunSummary` carries `elapsed`, the `RunEnd` (`Completed`, `Failed`, `Cancelled`, `TimedOut { server_cancelled }`) and the `TransactionState` after the run. `Unknown` is an answer of its own: the tab's close guard warns instead of guessing. @@ -356,7 +356,7 @@ tablepro_test_support::tls_conformance_suite! { } ``` -The cases cover listing and identity, deadlines within the cancel grace, a cancelled editor run, a dropped `query_bound` caller stopping the server's statement, a lost transport, a busy pool's probe, read-only refusal, write atomicity, row guards, the row limit, unbounded schema changes, streaming in bounded batches, type fallback and every TLS mode. [testing.md](testing.md#conformance) lists them with their flags. `suite_flags_match_driver_capabilities` runs without Docker and fails when a flag disagrees with the driver's capabilities, so a case cannot be skipped by declaring less. +The cases cover listing and identity, deadlines within the cancel grace, a cancelled editor run, a dropped `query_bound` caller or editor run stopping the server's statement, a lost transport, a busy pool's probe, read-only refusal, write atomicity, row guards, the row limit, unbounded schema changes, streaming in bounded batches, type fallback and every TLS mode. [testing.md](testing.md#conformance) lists them with their flags. `suite_flags_match_driver_capabilities` runs without Docker and fails when a flag disagrees with the driver's capabilities, so a case cannot be skipped by declaring less. Behaviour the suites do not cover gets a test of its own against the real engine: the engine's stop, its error mapping, what a pooler does to it, each write outcome the ledger can produce. diff --git a/linux/docs/testing.md b/linux/docs/testing.md index 3a4a693c7d..e36eead0e1 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -117,7 +117,8 @@ Each flag adds cases and needs its engine trait: | Flag | Adds | Needs | |---|---|---| -| `endpoint: network` | a dropped `query_bound` caller stops the server's statement; a lost transport reports `ConnectionLost` | `NetworkConformanceEngine` | +| `endpoint: network` | a dropped `query_bound` caller or editor run stops the server's statement, and the editor session then runs again or reports `Lost`; a lost transport reports `ConnectionLost` | `NetworkConformanceEngine` | +| `endpoint: file` | a dropped editor run stops, and the session takes the next run within the cancel grace | `ConformanceEngine` | | `pool: pooled` | a probe succeeds while every pool slot is busy | `PooledConformanceEngine` | | `editor_transactions: true` | the row limit keeps the transaction and the statements after it | `EditorTransactionEngine` | | `unsupported_types: true` | an unsupported type never decodes as NULL | `TypedConformanceEngine` | From 676601c2059f27ed5647a7321e11f1947df4b565 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:09:30 +0700 Subject: [PATCH 059/178] refactor(linux-core): plan a save in the order its changes are listed --- .../crates/app/src/services/change_tracker.rs | 59 +++++----- linux/crates/core/src/save/change_origin.rs | 10 -- linux/crates/core/src/save/change_set.rs | 56 +++++---- linux/crates/core/src/save/mod.rs | 4 +- linux/crates/core/src/save/plan_save.rs | 109 +++++++++--------- linux/crates/core/src/save/save_plan.rs | 12 +- .../tests/integration/keys_and_decimals.rs | 7 +- .../clickhouse/tests/integration/writes.rs | 12 +- .../mssql/tests/integration/write_batch.rs | 17 ++- .../drivers/mysql/tests/integration/decode.rs | 7 +- .../postgres/tests/integration/decode.rs | 15 +-- .../postgres/tests/integration/metadata.rs | 7 +- .../sqlite/tests/integration/decode.rs | 12 +- .../src/conformance/cases/row_guards.rs | 7 +- 14 files changed, 160 insertions(+), 174 deletions(-) delete mode 100644 linux/crates/core/src/save/change_origin.rs diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index f2a8218646..4f6236e7db 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -7,7 +7,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use tablepro_core::dml::BuildSqlError; use tablepro_core::edit::CellInput; use tablepro_core::meta::RowIdentity; -use tablepro_core::save::{ChangeOrigin, ChangeSet, RowInsert, RowUpdate}; +use tablepro_core::save::{ChangeSet, RowChange, RowUpdate}; use tablepro_core::{ColumnInfo, Value}; use super::cell_overlay::CellOverlay; @@ -476,8 +476,8 @@ impl TabChangeTracker { .zip(&draft.values) .map(|(column, value)| draft_cell(column, value)) .collect(); - tracked.changes.inserts.push(RowInsert { cells }); - tracked.inserts.push(StatementSource::Insert { + tracked.changes.changes.push(RowChange::Insert { cells }); + tracked.sources.push(StatementSource::Insert { draft_id: draft.draft_id, }); } @@ -497,11 +497,11 @@ impl TabChangeTracker { }; require_key(columns)?; assignments.sort_by_key(|(col, _)| *col); - tracked.changes.updates.push(RowUpdate { + tracked.changes.changes.push(RowChange::Update(RowUpdate { key: key.iter().map(|part| part.value().clone()).collect(), assignments, - }); - tracked.updates.push(StatementSource::Update { row_key }); + })); + tracked.sources.push(StatementSource::Update { row_key }); } for row_key in self.deletes.keys() { @@ -509,11 +509,10 @@ impl TabChangeTracker { continue; }; require_key(columns)?; - tracked - .changes - .deletes - .push(key.iter().map(|part| part.value().clone()).collect()); - tracked.deletes.push(StatementSource::Delete { + tracked.changes.changes.push(RowChange::Delete { + key: key.iter().map(|part| part.value().clone()).collect(), + }); + tracked.sources.push(StatementSource::Delete { row_key: row_key.clone(), }); } @@ -526,20 +525,13 @@ impl TabChangeTracker { #[derive(Debug, Clone, Default)] pub struct TrackedChanges { pub changes: ChangeSet, - inserts: Vec, - updates: Vec, - deletes: Vec, + sources: Vec, } impl TrackedChanges { - /// The grid row behind one step of the planned save. - pub fn source(&self, origin: ChangeOrigin) -> Option { - match origin { - ChangeOrigin::Insert(index) => self.inserts.get(index), - ChangeOrigin::Update(index) => self.updates.get(index), - ChangeOrigin::Delete(index) => self.deletes.get(index), - } - .cloned() + /// The grid row behind one change, by its position in `changes`. + pub fn source(&self, change: usize) -> Option { + self.sources.get(change).cloned() } } @@ -807,11 +799,12 @@ mod tests { let tracked = t.change_set(&columns).unwrap(); - assert_eq!(tracked.changes.updates.len(), 1); - assert_eq!(tracked.changes.updates[0].key, vec![Value::Int(1), Value::Int(2)]); assert_eq!( - tracked.changes.updates[0].assignments, - vec![(2, Value::Text("new".into()))] + tracked.changes.changes, + vec![RowChange::Update(RowUpdate { + key: vec![Value::Int(1), Value::Int(2)], + assignments: vec![(2, Value::Text("new".into()))], + })] ); assert_eq!( pk_identity(&columns), @@ -838,12 +831,14 @@ mod tests { let tracked = t.change_set(&columns).unwrap(); assert_eq!( - tracked.changes.inserts[0].cells, - vec![ - CellInput::Default, - CellInput::Value(Value::Text("draft".into())), - CellInput::Default - ] + tracked.changes.changes, + vec![RowChange::Insert { + cells: vec![ + CellInput::Default, + CellInput::Value(Value::Text("draft".into())), + CellInput::Default + ] + }] ); } diff --git a/linux/crates/core/src/save/change_origin.rs b/linux/crates/core/src/save/change_origin.rs deleted file mode 100644 index da635936e7..0000000000 --- a/linux/crates/core/src/save/change_origin.rs +++ /dev/null @@ -1,10 +0,0 @@ -/// Which change in the grid a write step came from. -/// -/// A save is a list of statements, and when one fails the user has to -/// be told which row it was. This is how a step points back. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChangeOrigin { - Insert(usize), - Update(usize), - Delete(usize), -} diff --git a/linux/crates/core/src/save/change_set.rs b/linux/crates/core/src/save/change_set.rs index a95fc27f80..9fe60af534 100644 --- a/linux/crates/core/src/save/change_set.rs +++ b/linux/crates/core/src/save/change_set.rs @@ -1,22 +1,31 @@ use crate::edit::CellInput; use crate::value::Value; -/// What the user changed in the grid, before any of it is SQL. +/// What the user changed in the grid, before any of it is SQL, in the +/// order the save runs it. /// -/// Keys are in `key_components` order, the same order the page laid -/// them out, so a key read from a row can be bound straight back. +/// The order is the caller's: a row deleted and then replaced by a new +/// row with the same unique value only saves when the DELETE runs +/// first. Keys are in `key_components` order, the same order the page +/// laid them out, so a key read from a row can be bound straight back. #[derive(Debug, Clone, Default, PartialEq)] pub struct ChangeSet { - pub inserts: Vec, - pub updates: Vec, - pub deletes: Vec>, + pub changes: Vec, } -/// A draft row. A cell the user left alone is `Default`, which means -/// the insert omits the column and the server fills it in. +/// One row's change. #[derive(Debug, Clone, PartialEq)] -pub struct RowInsert { - pub cells: Vec, +pub enum RowChange { + /// A draft row. A cell the user left alone is `Default`, which + /// means the insert omits the column and the server fills it in. + Insert { + cells: Vec, + }, + Update(RowUpdate), + /// The key the row had when it was read. + Delete { + key: Vec, + }, } /// One row's edits, against the key it had when it was read. @@ -29,13 +38,13 @@ pub struct RowUpdate { impl ChangeSet { pub fn is_empty(&self) -> bool { - self.inserts.is_empty() && self.updates.is_empty() && self.deletes.is_empty() + self.changes.is_empty() } /// How many statements this will become, which is what the /// progress label counts. pub fn step_count(&self) -> usize { - self.inserts.len() + self.updates.len() + self.deletes.len() + self.changes.len() } } @@ -54,14 +63,21 @@ mod tests { #[test] fn steps_count_every_kind_of_change() { let changes = ChangeSet { - inserts: vec![RowInsert { - cells: vec![CellInput::Default], - }], - updates: vec![RowUpdate { - key: vec![Value::Int(1)], - assignments: vec![(1, Value::Text("x".to_owned()))], - }], - deletes: vec![vec![Value::Int(2)], vec![Value::Int(3)]], + changes: vec![ + RowChange::Insert { + cells: vec![CellInput::Default], + }, + RowChange::Update(RowUpdate { + key: vec![Value::Int(1)], + assignments: vec![(1, Value::Text("x".to_owned()))], + }), + RowChange::Delete { + key: vec![Value::Int(2)], + }, + RowChange::Delete { + key: vec![Value::Int(3)], + }, + ], }; assert!(!changes.is_empty()); diff --git a/linux/crates/core/src/save/mod.rs b/linux/crates/core/src/save/mod.rs index 0f5f9778d2..c74dc57199 100644 --- a/linux/crates/core/src/save/mod.rs +++ b/linux/crates/core/src/save/mod.rs @@ -1,11 +1,9 @@ //! Turning what the user changed in the grid into statements. -mod change_origin; mod change_set; mod plan_save; mod save_plan; -pub use change_origin::ChangeOrigin; -pub use change_set::{ChangeSet, RowInsert, RowUpdate}; +pub use change_set::{ChangeSet, RowChange, RowUpdate}; pub use plan_save::plan_save; pub use save_plan::SavePlan; diff --git a/linux/crates/core/src/save/plan_save.rs b/linux/crates/core/src/save/plan_save.rs index a175c26ad0..bc505e1839 100644 --- a/linux/crates/core/src/save/plan_save.rs +++ b/linux/crates/core/src/save/plan_save.rs @@ -7,10 +7,10 @@ use crate::statement::{GuardedStatement, RowGuard, Statement}; use crate::value::Value; use crate::write_batch::{WriteBatch, WriteKind}; -use super::{ChangeOrigin, ChangeSet, SavePlan}; +use super::{ChangeSet, RowChange, SavePlan}; -/// The batch that writes what the user changed, inserts first, then -/// updates, then deletes. +/// The batch that writes what the user changed, one statement per +/// change, in the change set's order. /// /// Every update and delete carries a guard that proves it touched /// exactly the row it names, chosen by how far the key and the engine's @@ -30,20 +30,20 @@ pub fn plan_save( let mut steps = Vec::with_capacity(changes.step_count()); let mut origins = Vec::with_capacity(changes.step_count()); - for (index, insert) in changes.inserts.iter().enumerate() { - let statement = build_insert(dialect, table, columns, &insert.cells)?; - steps.push(GuardedStatement::unguarded(statement)); - origins.push(ChangeOrigin::Insert(index)); - } - for (index, update) in changes.updates.iter().enumerate() { - let statement = build_update(dialect, table, columns, identity, &update.key, &update.assignments)?; - steps.push(guarded(dialect, table, columns, identity, &update.key, statement)?); - origins.push(ChangeOrigin::Update(index)); - } - for (index, key) in changes.deletes.iter().enumerate() { - let statement = build_delete(dialect, table, columns, identity, key)?; - steps.push(guarded(dialect, table, columns, identity, key, statement)?); - origins.push(ChangeOrigin::Delete(index)); + for (index, change) in changes.changes.iter().enumerate() { + let step = match change { + RowChange::Insert { cells } => GuardedStatement::unguarded(build_insert(dialect, table, columns, cells)?), + RowChange::Update(update) => { + let statement = build_update(dialect, table, columns, identity, &update.key, &update.assignments)?; + guarded(dialect, table, columns, identity, &update.key, statement)? + } + RowChange::Delete { key } => { + let statement = build_delete(dialect, table, columns, identity, key)?; + guarded(dialect, table, columns, identity, key, statement)? + } + }; + steps.push(step); + origins.push(index); } Ok(SavePlan { @@ -84,7 +84,7 @@ mod tests { use crate::dialect::TestDialect; use crate::edit::CellInput; use crate::meta::EngineRowId; - use crate::save::{RowInsert, RowUpdate}; + use crate::save::RowUpdate; use super::*; @@ -135,16 +135,26 @@ mod tests { } } + fn insert(name: &str) -> RowChange { + RowChange::Insert { + cells: vec![CellInput::Default, CellInput::Value(Value::Text(name.to_owned()))], + } + } + + fn update(key: Value, name: &str) -> RowChange { + RowChange::Update(RowUpdate { + key: vec![key], + assignments: vec![(1, Value::Text(name.to_owned()))], + }) + } + + fn delete(key: Value) -> RowChange { + RowChange::Delete { key: vec![key] } + } + fn one_of_each(key: Value) -> ChangeSet { ChangeSet { - inserts: vec![RowInsert { - cells: vec![CellInput::Default, CellInput::Value(Value::Text("new".to_owned()))], - }], - updates: vec![RowUpdate { - key: vec![key.clone()], - assignments: vec![(1, Value::Text("renamed".to_owned()))], - }], - deletes: vec![vec![key]], + changes: vec![insert("new"), update(key.clone(), "renamed"), delete(key)], } } @@ -232,40 +242,30 @@ mod tests { } #[test] - fn plan_save_origins_align_with_steps() { + fn plan_save_keeps_the_change_order_and_origins_align_with_steps() { let changes = ChangeSet { - inserts: vec![ - RowInsert { - cells: vec![CellInput::Default, CellInput::Value(Value::Text("a".to_owned()))], - }, - RowInsert { - cells: vec![CellInput::Default, CellInput::Value(Value::Text("b".to_owned()))], - }, + changes: vec![ + delete(Value::Int(2)), + insert("a"), + update(Value::Int(1), "c"), + insert("b"), + delete(Value::Int(3)), ], - updates: vec![RowUpdate { - key: vec![Value::Int(1)], - assignments: vec![(1, Value::Text("c".to_owned()))], - }], - deletes: vec![vec![Value::Int(2)], vec![Value::Int(3)]], }; let plan = plan(&TestDialect::exact(), &by_id(), &changes); - assert_eq!( - plan.origins, - vec![ - ChangeOrigin::Insert(0), - ChangeOrigin::Insert(1), - ChangeOrigin::Update(0), - ChangeOrigin::Delete(0), - ChangeOrigin::Delete(1), - ] - ); + assert_eq!(plan.origins, vec![0, 1, 2, 3, 4]); assert_eq!(plan.origins.len(), plan.batch.steps.len()); - assert!(plan.batch.steps[0].statement.sql().starts_with("INSERT")); - assert!(plan.batch.steps[2].statement.sql().starts_with("UPDATE")); - assert!(plan.batch.steps[4].statement.sql().starts_with("DELETE")); - assert_eq!(plan.origin(4), Some(ChangeOrigin::Delete(1))); + let verbs: Vec<_> = plan + .batch + .steps + .iter() + .map(|step| step.statement.sql().split(' ').next().unwrap_or_default()) + .collect(); + assert_eq!(verbs, vec!["DELETE", "INSERT", "UPDATE", "INSERT", "DELETE"]); + assert_eq!(plan.origin(4), Some(4)); + assert_eq!(plan.origin(5), None); } #[test] @@ -280,8 +280,7 @@ mod tests { #[test] fn an_unkeyed_table_takes_inserts_but_refuses_row_changes() { let inserts_only = ChangeSet { - inserts: one_of_each(Value::Int(7)).inserts, - ..ChangeSet::default() + changes: vec![insert("new")], }; let plan = plan(&TestDialect::exact(), &RowIdentity::Unordered, &inserts_only); diff --git a/linux/crates/core/src/save/save_plan.rs b/linux/crates/core/src/save/save_plan.rs index 9e0477ebd8..4e8e995721 100644 --- a/linux/crates/core/src/save/save_plan.rs +++ b/linux/crates/core/src/save/save_plan.rs @@ -1,21 +1,21 @@ use crate::write_batch::WriteBatch; -use super::ChangeOrigin; - /// The statements a save will run, each pointing back at the change it /// came from. /// /// `origins` is the same length as the batch's steps and in the same -/// order, so a failure at step 3 names the row the user edited rather -/// than a statement number. +/// order, and each entry is a position in `ChangeSet::changes`, so a +/// failure at step 3 names the row the user edited rather than a +/// statement number. #[derive(Debug, Clone, PartialEq)] pub struct SavePlan { pub batch: WriteBatch, - pub origins: Vec, + pub origins: Vec, } impl SavePlan { - pub fn origin(&self, step: usize) -> Option { + /// The position in the change set behind one step. + pub fn origin(&self, step: usize) -> Option { self.origins.get(step).copied() } } diff --git a/linux/crates/drivers/clickhouse/tests/integration/keys_and_decimals.rs b/linux/crates/drivers/clickhouse/tests/integration/keys_and_decimals.rs index 9c6141dd59..d8179ae8d8 100644 --- a/linux/crates/drivers/clickhouse/tests/integration/keys_and_decimals.rs +++ b/linux/crates/drivers/clickhouse/tests/integration/keys_and_decimals.rs @@ -1,7 +1,7 @@ //! Values that only survive a round trip when every digit and byte is //! kept: wide numbers, sub-second keys and keys that are not UTF-8. -use tablepro_core::save::{ChangeSet, RowUpdate}; +use tablepro_core::save::{ChangeSet, RowChange, RowUpdate}; use tablepro_core::value::{SqlDecimal, Temporal}; use tablepro_core::{Connection, Value}; @@ -9,11 +9,10 @@ use crate::support::{CALL, connect, count, planned, quoted, rows, setup, start, async fn edit(connection: &dyn Connection, name: &str, key: Vec, column: usize, value: Value) { let changes = ChangeSet { - updates: vec![RowUpdate { + changes: vec![RowChange::Update(RowUpdate { key, assignments: vec![(column, value)], - }], - ..ChangeSet::default() + })], }; let batch = planned(connection, &table(name), &changes).await; connection diff --git a/linux/crates/drivers/clickhouse/tests/integration/writes.rs b/linux/crates/drivers/clickhouse/tests/integration/writes.rs index f09c159a24..e8356df450 100644 --- a/linux/crates/drivers/clickhouse/tests/integration/writes.rs +++ b/linux/crates/drivers/clickhouse/tests/integration/writes.rs @@ -3,7 +3,7 @@ use std::time::Duration; -use tablepro_core::save::{ChangeSet, RowUpdate}; +use tablepro_core::save::{ChangeSet, RowChange, RowUpdate}; use tablepro_core::{ CommitResolution, CommitToken, Connection, DriverError, ReadOnlyRefusal, RunEnd, ServerCode, StatementOutcome, Value, WriteKind, @@ -72,11 +72,10 @@ async fn duplicate_sorting_key_edit_sends_no_mutation_row_guard_failed() { let before = count(connection.as_ref(), &mutations).await; let changes = ChangeSet { - updates: vec![RowUpdate { + changes: vec![RowChange::Update(RowUpdate { key: vec![Value::Int(1)], assignments: vec![(1, Value::Text("x".to_owned()))], - }], - ..ChangeSet::default() + })], }; let batch = planned(connection.as_ref(), &table(&name), &changes).await; let outcome = connection.write_batch(&batch, within(CALL)).await; @@ -157,11 +156,10 @@ async fn write_statements_never_carry_max_execution_time() { ) .await; let changes = ChangeSet { - updates: vec![RowUpdate { + changes: vec![RowChange::Update(RowUpdate { key: vec![Value::Int(1)], assignments: vec![(1, Value::Text("b".to_owned()))], - }], - ..ChangeSet::default() + })], }; let batch = planned(connection.as_ref(), &table(&name), &changes).await; connection.write_batch(&batch, within(CALL)).await.expect("the edit"); diff --git a/linux/crates/drivers/mssql/tests/integration/write_batch.rs b/linux/crates/drivers/mssql/tests/integration/write_batch.rs index ff481dd2d4..9da1591240 100644 --- a/linux/crates/drivers/mssql/tests/integration/write_batch.rs +++ b/linux/crates/drivers/mssql/tests/integration/write_batch.rs @@ -5,7 +5,7 @@ use std::time::Duration; use tablepro_core::edit::parse_literal_text; use tablepro_core::meta::TableRef; -use tablepro_core::save::{ChangeSet, RowUpdate, plan_save}; +use tablepro_core::save::{ChangeSet, RowChange, RowUpdate, plan_save}; use tablepro_core::{CallOptions, DriverError, ErrorCategory, Value, WriteBatch}; use tokio::time::Instant; use tokio_util::sync::CancellationToken; @@ -20,11 +20,10 @@ async fn update_label(connection: &dyn tablepro_core::Connection, table: &TableR .position(|column| column.name == "label") .expect("a label column"); let changes = ChangeSet { - updates: vec![RowUpdate { + changes: vec![RowChange::Update(RowUpdate { key: vec![key], assignments: vec![(label, Value::Text("changed".to_owned()))], - }], - ..ChangeSet::default() + })], }; plan_save(connection.dialect().as_ref(), table, &columns, &identity, &changes) .expect("a save plan") @@ -52,11 +51,10 @@ async fn edited_xml_cell_saves() { .expect("an identity"); let edited = parse_literal_text("edited", &columns[1].column_type).expect("an XML edit"); let changes = ChangeSet { - updates: vec![RowUpdate { + changes: vec![RowChange::Update(RowUpdate { key: vec![Value::Int(1)], assignments: vec![(1, edited)], - }], - ..ChangeSet::default() + })], }; let save = plan_save(connection.dialect().as_ref(), &table, &columns, &identity, &changes) .expect("a save plan") @@ -93,11 +91,10 @@ async fn datetime_key_with_a_third_of_a_millisecond_saves() { .expect("an identity"); let key = browse(connection.as_ref(), &table).await.remove(0).remove(0); let changes = ChangeSet { - updates: vec![RowUpdate { + changes: vec![RowChange::Update(RowUpdate { key: vec![key], assignments: vec![(1, Value::Text("changed".to_owned()))], - }], - ..ChangeSet::default() + })], }; let save = plan_save(connection.dialect().as_ref(), &table, &columns, &identity, &changes) .expect("a save plan") diff --git a/linux/crates/drivers/mysql/tests/integration/decode.rs b/linux/crates/drivers/mysql/tests/integration/decode.rs index 535edee90d..9e3135690f 100644 --- a/linux/crates/drivers/mysql/tests/integration/decode.rs +++ b/linux/crates/drivers/mysql/tests/integration/decode.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use tablepro_core::column::{BoolStorage, ColumnKind, FloatWidth, TemporalZone}; -use tablepro_core::save::{ChangeSet, RowUpdate, plan_save}; +use tablepro_core::save::{ChangeSet, RowChange, RowUpdate, plan_save}; use tablepro_core::value::Temporal; use tablepro_core::{Connection, Value}; @@ -90,11 +90,10 @@ async fn edit_by_max_id(connection: &Arc, id_type: &str, id: Val .position(|column| column.name == "label") .expect("a label column"); let changes = ChangeSet { - updates: vec![RowUpdate { + changes: vec![RowChange::Update(RowUpdate { key: vec![read[0][0].clone()], assignments: vec![(label, Value::Text("after".to_owned()))], - }], - ..ChangeSet::default() + })], }; let dialect = connection.dialect(); let plan = plan_save(dialect.as_ref(), &target, &columns, &identity, &changes).expect("a plan"); diff --git a/linux/crates/drivers/postgres/tests/integration/decode.rs b/linux/crates/drivers/postgres/tests/integration/decode.rs index 5a48edcdcb..7bdf268041 100644 --- a/linux/crates/drivers/postgres/tests/integration/decode.rs +++ b/linux/crates/drivers/postgres/tests/integration/decode.rs @@ -6,7 +6,7 @@ use tablepro_core::browse::{PageRequest, build_page, split_page}; use tablepro_core::edit::CellInput; use tablepro_core::filter::FilterSet; use tablepro_core::meta::{PagePosition, RowIdentity, TableRef}; -use tablepro_core::save::{ChangeSet, RowInsert, RowUpdate, plan_save}; +use tablepro_core::save::{ChangeSet, RowChange, RowUpdate, plan_save}; use tablepro_core::value::{ BitString, JsonText, OffsetTimestamp, OtherValue, SqlDecimal, SqlInterval, SqlTime, Temporal, TimeWithOffset, }; @@ -130,10 +130,9 @@ async fn round_trip_matrix() { let table = TableRef::new(Some("public".to_owned()), "matrix"); let written = matrix_row(); let changes = ChangeSet { - inserts: vec![RowInsert { + changes: vec![RowChange::Insert { cells: written.iter().cloned().map(CellInput::Value).collect(), }], - ..ChangeSet::default() }; save(connection.as_ref(), &table, &changes).await; @@ -177,11 +176,10 @@ async fn set_null_saves_typed_null() { .await; let table = TableRef::new(Some("public".to_owned()), "t"); let changes = ChangeSet { - updates: vec![RowUpdate { + changes: vec![RowChange::Update(RowUpdate { key: vec![Value::Int(1)], assignments: (1..=4).map(|column| (column, Value::Null)).collect(), - }], - ..ChangeSet::default() + })], }; save(connection.as_ref(), &table, &changes).await; @@ -209,11 +207,10 @@ async fn char10_key_update_affects_one_row() { .await .expect("the identity"); let changes = ChangeSet { - updates: vec![RowUpdate { + changes: vec![RowChange::Update(RowUpdate { key: vec![Value::Text("ab".to_owned())], assignments: vec![(1, Value::Text("changed".to_owned()))], - }], - ..ChangeSet::default() + })], }; save(connection.as_ref(), &table, &changes).await; diff --git a/linux/crates/drivers/postgres/tests/integration/metadata.rs b/linux/crates/drivers/postgres/tests/integration/metadata.rs index 805f68430f..f1cf5ac91a 100644 --- a/linux/crates/drivers/postgres/tests/integration/metadata.rs +++ b/linux/crates/drivers/postgres/tests/integration/metadata.rs @@ -6,7 +6,7 @@ use tablepro_core::column::{ }; use tablepro_core::ddl::{CollationName, GeneratedStorage, IdentityStyle}; use tablepro_core::meta::{EngineRowId, IndexKey, ObjectKind, ObjectScope, RowCountEstimate, RowIdentity, TableRef}; -use tablepro_core::save::{ChangeSet, RowUpdate, plan_save}; +use tablepro_core::save::{ChangeSet, RowChange, RowUpdate, plan_save}; use tablepro_core::{DriverError, Value}; use tablepro_test_fixtures::{HbaMode, PostgresFixture, PostgresVersion}; @@ -211,11 +211,10 @@ async fn stale_ctid_edit_after_vacuum_reuse_row_guard_failed() { .expect("the identity"); let dialect = connection.dialect(); let changes = ChangeSet { - updates: vec![RowUpdate { + changes: vec![RowChange::Update(RowUpdate { key: before[0].clone(), assignments: vec![(0, Value::Text("changed".to_owned()))], - }], - ..ChangeSet::default() + })], }; let plan = plan_save(dialect.as_ref(), &target, &columns, &identity, &changes).expect("a plan"); diff --git a/linux/crates/drivers/sqlite/tests/integration/decode.rs b/linux/crates/drivers/sqlite/tests/integration/decode.rs index 030fe7b564..700d5916df 100644 --- a/linux/crates/drivers/sqlite/tests/integration/decode.rs +++ b/linux/crates/drivers/sqlite/tests/integration/decode.rs @@ -3,7 +3,7 @@ use tablepro_core::column::{ColumnKind, FloatWidth, IntegerKind, IntegerWidth}; use tablepro_core::meta::TableRef; -use tablepro_core::save::{ChangeSet, RowUpdate, plan_save}; +use tablepro_core::save::{ChangeSet, RowChange, RowUpdate, plan_save}; use tablepro_core::{RowLimit, Value}; use crate::support::{TestFile, apply, call, query, statement}; @@ -104,7 +104,7 @@ async fn strict_table_round_trip() { let row = read.rows[0].clone(); let changes = ChangeSet { - updates: vec![RowUpdate { + changes: vec![RowChange::Update(RowUpdate { key: vec![row[0].clone()], assignments: vec![ (1, Value::Int(i64::MIN)), @@ -112,8 +112,7 @@ async fn strict_table_round_trip() { (3, Value::Text("ü'".to_owned())), (4, Value::Bytes(vec![1, 2, 3])), ], - }], - ..ChangeSet::default() + })], }; let dialect = connection.dialect(); let plan = plan_save(dialect.as_ref(), &table, &columns, &identity, &changes).unwrap(); @@ -169,8 +168,9 @@ async fn a_date_column_keeps_the_spelling_it_was_written_with() { // The value names its own row, so a save keyed by it must match the // row as stored, T and Z included. let changes = ChangeSet { - deletes: vec![vec![Value::Int(1)]], - ..ChangeSet::default() + changes: vec![RowChange::Delete { + key: vec![Value::Int(1)], + }], }; let identity = connection.row_identity(&table, call()).await.unwrap(); let dialect = connection.dialect(); diff --git a/linux/crates/test-support/src/conformance/cases/row_guards.rs b/linux/crates/test-support/src/conformance/cases/row_guards.rs index cee80fd3ff..859758c91f 100644 --- a/linux/crates/test-support/src/conformance/cases/row_guards.rs +++ b/linux/crates/test-support/src/conformance/cases/row_guards.rs @@ -1,5 +1,5 @@ use tablepro_core::meta::RowIdentity; -use tablepro_core::save::{ChangeSet, RowUpdate, plan_save}; +use tablepro_core::save::{ChangeSet, RowChange, RowUpdate, plan_save}; use tablepro_core::statement::RowGuard; use tablepro_core::value::Value; use tablepro_core::{ColumnInfo, Connection, DriverError, TableRef, WriteBatch, WriteKind}; @@ -77,11 +77,10 @@ fn relabel( id: i64, ) -> Result { let changes = ChangeSet { - updates: vec![RowUpdate { + changes: vec![RowChange::Update(RowUpdate { key: vec![Value::Int(id)], assignments: vec![(label, Value::Text("changed".to_owned()))], - }], - ..ChangeSet::default() + })], }; let plan = plan_save(connection.dialect().as_ref(), table, columns, identity, &changes) .map_err(ConformanceFailure::setup)?; From 2ddb2fe33549342253621f315bc1cf8d4e696f6d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:12:30 +0700 Subject: [PATCH 060/178] fix(linux): undo restores the prior edit and saves follow edit order --- linux/ARCHITECTURE.md | 2 +- linux/CHANGELOG.md | 2 + linux/Cargo.lock | 1 + linux/crates/app/Cargo.toml | 1 + linux/crates/app/src/lib.rs | 2 + .../crates/app/src/services/change_tracker.rs | 679 ++++++++++++------ linux/crates/app/src/services/mod.rs | 2 + .../app/src/services/tab_tracker_handle.rs | 23 + linux/crates/app/src/services/touch.rs | 12 + linux/crates/app/src/services/undo_op.rs | 46 ++ .../crates/app/tests/tracker_save_postgres.rs | 131 ++++ 11 files changed, 669 insertions(+), 232 deletions(-) create mode 100644 linux/crates/app/src/services/touch.rs create mode 100644 linux/crates/app/src/services/undo_op.rs create mode 100644 linux/crates/app/tests/tracker_save_postgres.rs diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 4bbfff2a81..b38c9b04b1 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -221,7 +221,7 @@ Each window owns two registries holding the in-flight edit state for its tabs, k | `ChangeTrackerRegistry` | `TabTrackerHandle` | Row-level INSERT / UPDATE / DELETE for Table tabs | `TabTrackerHandle::change_set` → `TrackedChanges` | | `StructureTrackerRegistry` | `StructureTrackerHandle` | Column / index / FK / table-rename DDL for Structure tabs | `sql_ddl::materialize_ops` → `Vec` | -A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to rebind, the draft rows the list store gains, loses or updates, and the new pending count. The tab applies it to its `gio::ListStore` after the call returns. `App::dirty_tabs` reads both registries for the close prompts, the teardown guard and the `•` title prefix. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. +A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to rebind, the draft rows the list store gains, loses or updates, and the new pending count. The tab applies it to its `gio::ListStore` after the call returns. Every pending change carries a `Touch` from the tracker's counter. An `UndoOp` holds the pending state on both sides of a step, touch included, so undo after A, B, C shows B, not the fetched value. `change_set` emits one change per row in touch order, and `plan_save` keeps that order: a row deleted and replaced by a draft with the same unique value saves, because its DELETE runs first. `App::dirty_tabs` reads both registries for the close prompts, the teardown guard and the `•` title prefix. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. The Structure tab is **snapshot + diff**, not per-op log. `original_*` snapshots capture the load-time schema; the diff against the live model produces ops via `sql_ddl::diff_to_ops`. Discard restores the snapshot. There is no per-op undo: Discard is the only restore point. The tracker just caches the most recent diff so out-of-band callers (close prompt, save dispatcher) read the same op list without re-deriving. diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 7f68f55227..dd6808046a 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ClickHouse save with an unknown outcome could read as applied because of another table's mutation. - A new row removed with Delete stayed in the grid while the save left it out. - A save that failed on a new row did not flash that row. +- Undo after editing a cell twice restored the fetched value instead of the first edit. +- A save that deleted a row and added a new one with the same unique value failed. - The window title showed no unsaved-changes bullet for pending structure changes. - `real` values showed extra digits in the grid, copies and exports, such as 0.10000000149011612 for 0.1. - Excel export rounded integers past 2^53 and wrote decimals as text when their column padded the scale. diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 56873a98cb..3b835c0007 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -4334,6 +4334,7 @@ dependencies = [ "tablepro-session", "tablepro-ssh", "tablepro-storage", + "tablepro-test-fixtures", "tablepro-test-support", "tempfile", "thiserror", diff --git a/linux/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml index c681eb4e2b..6b9ac9de5f 100644 --- a/linux/crates/app/Cargo.toml +++ b/linux/crates/app/Cargo.toml @@ -65,6 +65,7 @@ sqlformat = "0.5" [dev-dependencies] tablepro-session = { path = "../session", features = ["fakes"] } +tablepro-test-fixtures = { path = "../test-fixtures" } tablepro-test-support = { path = "../test-support" } tokio = { workspace = true, features = ["macros", "rt", "test-util"] } tempfile.workspace = true diff --git a/linux/crates/app/src/lib.rs b/linux/crates/app/src/lib.rs index b26cc6ec8d..e32f316ef3 100644 --- a/linux/crates/app/src/lib.rs +++ b/linux/crates/app/src/lib.rs @@ -19,6 +19,8 @@ mod persistence; pub mod runtime; mod services; pub mod storage; +// Public for the save tests that run a tracker against a real server. +pub use services::{change_tracker, tab_tracker_handle, tracker_edit}; #[cfg(test)] mod test_support; mod ui; diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index 4f6236e7db..d3c45a4a79 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -11,8 +11,10 @@ use tablepro_core::save::{ChangeSet, RowChange, RowUpdate}; use tablepro_core::{ColumnInfo, Value}; use super::cell_overlay::CellOverlay; +use super::touch::Touch; use super::tracker_delta::{TrackerDelta, VisualChange}; use super::tracker_edit::TrackerEdit; +use super::undo_op::UndoOp; const UNDO_LIMIT: usize = 50; @@ -56,14 +58,24 @@ pub type KeyValue = tablepro_core::value::ValueIdentity; pub struct DraftRow { pub draft_id: u64, pub values: Vec, + /// The insert or the latest edit to one of its cells. + pub touched: Touch, } -/// One pending cell modification. `prev_value` is what the cell held -/// before the user touched it (used for undo + visual revert). -#[derive(Debug, Clone)] +/// A pending edit to a fetched cell. +#[derive(Debug, Clone, PartialEq)] pub struct CellEdit { - pub prev_value: Value, - pub new_value: Value, + /// The fetched value. An edit back to it drops the entry. + pub original: Value, + pub pending: Value, + pub touched: Touch, +} + +/// A fetched row marked for delete, with the row as it was read. +#[derive(Debug, Clone, PartialEq)] +pub struct DeleteMark { + pub original_values: Vec, + pub touched: Touch, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -90,33 +102,15 @@ pub enum StatementSource { Delete { row_key: RowKey }, } -/// Reversible action recorded for the per-tab undo stack. -#[derive(Debug, Clone)] -pub enum UndoOp { - /// Cell was edited. `prev_value` is what it held before this op. - CellEdit { - row_key: RowKey, - col: usize, - prev_value: Value, - new_value: Value, - }, - /// Draft row was added. Undoing removes it. - Insert { draft_id: u64, values: Vec }, - /// Row was marked for delete. Undoing unmarks. - Delete { - row_key: RowKey, - original_values: Vec, - }, -} - #[derive(Debug, Default)] pub struct TabChangeTracker { - inserts: Vec, - deletes: HashMap>, + drafts: Vec, + deletes: HashMap, updates: HashMap<(RowKey, usize), CellEdit>, undo: VecDeque, redo: VecDeque, next_draft_id: u64, + next_touch: Touch, /// Row behind the statement the last save failed on. error_row: Option, // A flash that starts inside an older flash's timeout must not be @@ -126,7 +120,7 @@ pub struct TabChangeTracker { impl TabChangeTracker { pub fn pending_count(&self) -> usize { - self.inserts.len() + self.deletes.len() + self.updates.len() + self.drafts.len() + self.deletes.len() + self.updates.len() } pub fn apply(&mut self, edit: TrackerEdit) -> TrackerDelta { @@ -155,6 +149,12 @@ impl TabChangeTracker { } } + fn touch(&mut self) -> Touch { + let touch = self.next_touch; + self.next_touch = touch.next(); + touch + } + fn push_undo(&mut self, op: UndoOp) { if self.undo.len() == UNDO_LIMIT { self.undo.pop_front(); @@ -163,203 +163,176 @@ impl TabChangeTracker { self.redo.clear(); } - /// Track a cell edit. If the cell was already modified, this - /// updates the new value but keeps the original `prev_value` (so - /// undoing restores the very-first untouched value, not the last - /// intermediate state). - fn track_cell_edit(&mut self, row_key: RowKey, col: usize, original: Value, new: Value) -> TrackerDelta { - let key = (row_key.clone(), col); - let prev_value = self - .updates - .get(&key) - .map(|e| e.prev_value.clone()) - .unwrap_or(original.clone()); - if prev_value == new { - self.updates.remove(&key); - } else { - self.updates.insert( - key, - CellEdit { - prev_value: prev_value.clone(), - new_value: new.clone(), - }, - ); - } + fn track_cell_edit(&mut self, row_key: RowKey, col: usize, original: Value, value: Value) -> TrackerDelta { + let before = self.updates.get(&(row_key.clone(), col)).cloned(); + let original = before.as_ref().map_or(original, |edit| edit.original.clone()); + let after = (value != original).then(|| CellEdit { + original, + pending: value, + touched: self.touch(), + }); self.push_undo(UndoOp::CellEdit { row_key: row_key.clone(), col, - prev_value: original, - new_value: new, + before, + after: after.clone(), }); - self.delta(vec![row_key], Vec::new()) + self.put_cell_edit(&row_key, col, after) } fn track_insert(&mut self, values: Vec) -> TrackerDelta { - let draft_id = self.next_draft_id; + let draft = DraftRow { + draft_id: self.next_draft_id, + values, + touched: self.touch(), + }; self.next_draft_id += 1; - self.inserts.push(DraftRow { - draft_id, - values: values.clone(), - }); - self.push_undo(UndoOp::Insert { - draft_id, - values: values.clone(), - }); - self.delta( - vec![RowKey::Draft(draft_id)], - vec![VisualChange::InsertDraft { draft_id, values }], - ) + self.push_undo(UndoOp::Insert { draft: draft.clone() }); + self.put_draft(draft) } - /// `original_values` is the full row at the time of the mark, for - /// the strikethrough render and for undo. fn track_delete(&mut self, row_key: RowKey, original_values: Vec) -> TrackerDelta { - self.deletes.insert(row_key.clone(), original_values.clone()); + let after = DeleteMark { + original_values, + touched: self.touch(), + }; self.push_undo(UndoOp::Delete { row_key: row_key.clone(), - original_values, + before: self.deletes.get(&row_key).cloned(), + after: after.clone(), }); - self.delta(vec![row_key], Vec::new()) + self.put_delete_mark(&row_key, Some(after)) } // The draft's undo and redo entries go with it, so Ctrl+Z cannot // bring it back half-edited. fn discard_draft(&mut self, draft_id: u64) -> TrackerDelta { - let before = self.inserts.len(); - self.inserts.retain(|d| d.draft_id != draft_id); - if self.inserts.len() == before { + let before = self.drafts.len(); + self.drafts.retain(|d| d.draft_id != draft_id); + if self.drafts.len() == before { return self.delta(Vec::new(), Vec::new()); } self.updates .retain(|(k, _), _| !matches!(k, RowKey::Draft(id) if *id == draft_id)); self.deletes .retain(|k, _| !matches!(k, RowKey::Draft(id) if *id == draft_id)); - self.undo.retain(|op| !undo_op_matches_draft(op, draft_id)); - self.redo.retain(|op| !undo_op_matches_draft(op, draft_id)); + self.undo.retain(|op| !op.is_about_draft(draft_id)); + self.redo.retain(|op| !op.is_about_draft(draft_id)); self.delta( vec![RowKey::Draft(draft_id)], vec![VisualChange::RemoveDraft { draft_id }], ) } - fn track_draft_cell_edit(&mut self, draft_id: u64, col: usize, new: Value) -> TrackerDelta { - let Some(prev) = self.draft_cell(draft_id, col) else { + fn track_draft_cell_edit(&mut self, draft_id: u64, col: usize, value: Value) -> TrackerDelta { + let Some((before, before_touch)) = self.draft_cell(draft_id, col) else { return self.delta(Vec::new(), Vec::new()); }; - let visual = self.set_draft_cell(draft_id, col, new.clone()); - self.push_undo(UndoOp::CellEdit { - row_key: RowKey::Draft(draft_id), + let after_touch = self.touch(); + self.push_undo(UndoOp::DraftCell { + draft_id, col, - prev_value: prev, - new_value: new, + before, + after: value.clone(), + before_touch, + after_touch, }); - self.delta(vec![RowKey::Draft(draft_id)], visual.into_iter().collect()) + self.put_draft_cell(draft_id, col, value, after_touch) } - fn draft_cell(&self, draft_id: u64, col: usize) -> Option { - self.inserts - .iter() - .find(|d| d.draft_id == draft_id) - .and_then(|d| d.values.get(col)) - .cloned() + fn draft_cell(&self, draft_id: u64, col: usize) -> Option<(Value, Touch)> { + let draft = self.drafts.iter().find(|d| d.draft_id == draft_id)?; + Some((draft.values.get(col)?.clone(), draft.touched)) + } + + fn put_cell_edit(&mut self, row_key: &RowKey, col: usize, edit: Option) -> TrackerDelta { + let key = (row_key.clone(), col); + match edit { + Some(edit) => self.updates.insert(key, edit), + None => self.updates.remove(&key), + }; + self.delta(vec![row_key.clone()], Vec::new()) + } + + fn put_delete_mark(&mut self, row_key: &RowKey, mark: Option) -> TrackerDelta { + match mark { + Some(mark) => self.deletes.insert(row_key.clone(), mark), + None => self.deletes.remove(row_key), + }; + self.delta(vec![row_key.clone()], Vec::new()) } - fn set_draft_cell(&mut self, draft_id: u64, col: usize, value: Value) -> Option { - let cell = self - .inserts - .iter_mut() - .find(|d| d.draft_id == draft_id) - .and_then(|d| d.values.get_mut(col))?; + fn put_draft(&mut self, draft: DraftRow) -> TrackerDelta { + let draft_id = draft.draft_id; + let visual = VisualChange::InsertDraft { + draft_id, + values: draft.values.clone(), + }; + self.drafts.push(draft); + self.delta(vec![RowKey::Draft(draft_id)], vec![visual]) + } + + fn remove_draft(&mut self, draft_id: u64) -> TrackerDelta { + self.drafts.retain(|d| d.draft_id != draft_id); + self.delta( + vec![RowKey::Draft(draft_id)], + vec![VisualChange::RemoveDraft { draft_id }], + ) + } + + fn put_draft_cell(&mut self, draft_id: u64, col: usize, value: Value, touched: Touch) -> TrackerDelta { + let Some(draft) = self.drafts.iter_mut().find(|d| d.draft_id == draft_id) else { + return self.delta(Vec::new(), Vec::new()); + }; + let Some(cell) = draft.values.get_mut(col) else { + return self.delta(Vec::new(), Vec::new()); + }; *cell = value.clone(); - Some(VisualChange::SetDraftCell { draft_id, col, value }) + draft.touched = touched; + self.delta( + vec![RowKey::Draft(draft_id)], + vec![VisualChange::SetDraftCell { draft_id, col, value }], + ) } pub fn undo(&mut self) -> Option { let op = self.undo.pop_back()?; - let (row_key, visual) = match &op { + let delta = match &op { UndoOp::CellEdit { - row_key: RowKey::Draft(draft_id), + row_key, col, before, .. + } => self.put_cell_edit(row_key, *col, before.clone()), + UndoOp::DraftCell { + draft_id, col, - prev_value, + before, + before_touch, .. - } => ( - RowKey::Draft(*draft_id), - self.set_draft_cell(*draft_id, *col, prev_value.clone()) - .into_iter() - .collect(), - ), - UndoOp::CellEdit { row_key, col, .. } => { - self.updates.remove(&(row_key.clone(), *col)); - (row_key.clone(), Vec::new()) - } - UndoOp::Insert { draft_id, .. } => { - self.inserts.retain(|d| d.draft_id != *draft_id); - ( - RowKey::Draft(*draft_id), - vec![VisualChange::RemoveDraft { draft_id: *draft_id }], - ) - } - UndoOp::Delete { row_key, .. } => { - self.deletes.remove(row_key); - (row_key.clone(), Vec::new()) - } + } => self.put_draft_cell(*draft_id, *col, before.clone(), *before_touch), + UndoOp::Insert { draft } => self.remove_draft(draft.draft_id), + UndoOp::Delete { row_key, before, .. } => self.put_delete_mark(row_key, before.clone()), }; self.redo.push_back(op); - Some(self.delta(vec![row_key], visual)) + Some(delta) } pub fn redo(&mut self) -> Option { let op = self.redo.pop_back()?; - let (row_key, visual) = match &op { + let delta = match &op { UndoOp::CellEdit { - row_key: RowKey::Draft(draft_id), + row_key, col, after, .. + } => self.put_cell_edit(row_key, *col, after.clone()), + UndoOp::DraftCell { + draft_id, col, - new_value, + after, + after_touch, .. - } => ( - RowKey::Draft(*draft_id), - self.set_draft_cell(*draft_id, *col, new_value.clone()) - .into_iter() - .collect(), - ), - UndoOp::CellEdit { - row_key, - col, - prev_value, - new_value, - } => { - self.updates.insert( - (row_key.clone(), *col), - CellEdit { - prev_value: prev_value.clone(), - new_value: new_value.clone(), - }, - ); - (row_key.clone(), Vec::new()) - } - UndoOp::Insert { draft_id, values } => { - self.inserts.push(DraftRow { - draft_id: *draft_id, - values: values.clone(), - }); - ( - RowKey::Draft(*draft_id), - vec![VisualChange::InsertDraft { - draft_id: *draft_id, - values: values.clone(), - }], - ) - } - UndoOp::Delete { - row_key, - original_values, - } => { - self.deletes.insert(row_key.clone(), original_values.clone()); - (row_key.clone(), Vec::new()) - } + } => self.put_draft_cell(*draft_id, *col, after.clone(), *after_touch), + UndoOp::Insert { draft } => self.put_draft(draft.clone()), + UndoOp::Delete { row_key, after, .. } => self.put_delete_mark(row_key, Some(after.clone())), }; self.undo.push_back(op); - Some(self.delta(vec![row_key], visual)) + Some(delta) } #[cfg(test)] @@ -381,17 +354,17 @@ impl TabChangeTracker { .map(|(row_key, _)| row_key) .chain(self.deletes.keys()) .cloned() - .chain(self.inserts.iter().map(|draft| RowKey::Draft(draft.draft_id))) + .chain(self.drafts.iter().map(|draft| RowKey::Draft(draft.draft_id))) .filter(|row_key| seen.insert(row_key.clone())) .collect(); let visual = self - .inserts + .drafts .iter() .map(|draft| VisualChange::RemoveDraft { draft_id: draft.draft_id, }) .collect(); - self.inserts.clear(); + self.drafts.clear(); self.deletes.clear(); self.updates.clear(); self.undo.clear(); @@ -434,7 +407,7 @@ impl TabChangeTracker { } pub fn drafts(&self) -> &[DraftRow] { - &self.inserts + &self.drafts } /// Mark the row a failed save points at. The returned generation @@ -457,66 +430,91 @@ impl TabChangeTracker { fn current_cell_value<'a>(&'a self, row_key: &RowKey, col: usize, original: &'a Value) -> &'a Value { match self.updates.get(&(row_key.clone(), col)) { - Some(edit) => &edit.new_value, + Some(edit) => &edit.pending, None => original, } } /// What the user changed, as the change set a save is planned from. /// - /// Rows are named by their primary key in column order, which is the - /// identity `pk_identity` describes, so the keys bind straight back. - /// A draft cell the user left empty asks for the server's default - /// where the column has one, as does any column the server fills. + /// One change per row, ordered by when the row was last touched, so + /// the statements run in the order the user made the edits: a row + /// deleted and then replaced by a draft with the same unique value + /// saves, because its DELETE runs first. Rows are named by their + /// primary key in column order, which is the identity `pk_identity` + /// describes, so the keys bind straight back. A draft cell the user + /// left empty asks for the server's default where the column has + /// one, as does any column the server fills. pub fn change_set(&self, columns: &[ColumnInfo]) -> Result { - let mut tracked = TrackedChanges::default(); - for draft in &self.inserts { + let mut rows: Vec<(Touch, RowChange, StatementSource)> = Vec::new(); + for draft in &self.drafts { let cells = columns .iter() .zip(&draft.values) .map(|(column, value)| draft_cell(column, value)) .collect(); - tracked.changes.changes.push(RowChange::Insert { cells }); - tracked.sources.push(StatementSource::Insert { - draft_id: draft.draft_id, - }); + rows.push(( + draft.touched, + RowChange::Insert { cells }, + StatementSource::Insert { + draft_id: draft.draft_id, + }, + )); } // One UPDATE per row, setting only the cells that moved, so a // concurrent edit to a column this user never touched survives. - let mut per_row: HashMap> = HashMap::new(); + let mut per_row = HashMap::new(); for ((row_key, col), edit) in &self.updates { - per_row - .entry(row_key.clone()) - .or_default() - .push((*col, edit.new_value.clone())); + if self.deletes.contains_key(row_key) { + continue; + } + per_row.entry(row_key).or_insert_with(Vec::new).push((*col, edit)); } - for (row_key, mut assignments) in per_row { - let RowKey::Persisted(key) = &row_key else { + for (row_key, mut cells) in per_row { + let RowKey::Persisted(key) = row_key else { continue; }; require_key(columns)?; - assignments.sort_by_key(|(col, _)| *col); - tracked.changes.changes.push(RowChange::Update(RowUpdate { - key: key.iter().map(|part| part.value().clone()).collect(), - assignments, - })); - tracked.sources.push(StatementSource::Update { row_key }); + cells.sort_by_key(|(col, _)| *col); + let touched = cells.iter().map(|(_, edit)| edit.touched).max().unwrap_or_default(); + rows.push(( + touched, + RowChange::Update(RowUpdate { + key: key_values(key), + assignments: cells + .into_iter() + .map(|(col, edit)| (col, edit.pending.clone())) + .collect(), + }), + StatementSource::Update { + row_key: row_key.clone(), + }, + )); } - for row_key in self.deletes.keys() { + for (row_key, mark) in &self.deletes { let RowKey::Persisted(key) = row_key else { continue; }; require_key(columns)?; - tracked.changes.changes.push(RowChange::Delete { - key: key.iter().map(|part| part.value().clone()).collect(), - }); - tracked.sources.push(StatementSource::Delete { - row_key: row_key.clone(), - }); + rows.push(( + mark.touched, + RowChange::Delete { key: key_values(key) }, + StatementSource::Delete { + row_key: row_key.clone(), + }, + )); } - Ok(tracked) + + // Every edit takes its own touch, so no two rows share one and + // the order does not depend on how the maps iterated. + rows.sort_by_key(|(touched, ..)| *touched); + let (changes, sources) = rows.into_iter().map(|(_, change, source)| (change, source)).unzip(); + Ok(TrackedChanges { + changes: ChangeSet { changes }, + sources, + }) } } @@ -554,6 +552,10 @@ fn require_key(columns: &[ColumnInfo]) -> Result<(), BuildSqlError> { } } +fn key_values(key: &[KeyValue]) -> Vec { + key.iter().map(|part| part.value().clone()).collect() +} + /// A NULL the user left in a column with a default means "use the /// default" (CURRENT_TIMESTAMP, gen_random_uuid()), not an explicit /// NULL over it. @@ -567,21 +569,6 @@ fn draft_cell(column: &ColumnInfo, value: &Value) -> CellInput { CellInput::Value(value.clone()) } -fn undo_op_matches_draft(op: &UndoOp, draft_id: u64) -> bool { - match op { - UndoOp::Insert { draft_id: id, .. } => *id == draft_id, - UndoOp::CellEdit { - row_key: RowKey::Draft(id), - .. - } => *id == draft_id, - UndoOp::Delete { - row_key: RowKey::Draft(id), - .. - } => *id == draft_id, - _ => false, - } -} - #[cfg(test)] mod tests { /// A column carrying the server's spelling and the kind it means, @@ -751,28 +738,258 @@ mod tests { (tracked, plan) } + fn text(value: &str) -> Value { + Value::Text(value.into()) + } + + fn edit(t: &mut TabChangeTracker, id: i64, from: &str, to: &str) { + t.track_cell_edit(rk(&[Value::Int(id)]), 1, text(from), text(to)); + } + + /// The cell as the grid draws it, over a fetched "a". + fn pending(t: &TabChangeTracker, id: i64) -> Value { + t.cell_overlay(&rk(&[Value::Int(id)]), 1, &text("a")).value + } + + /// Each change as a verb and the key or draft it names, which is + /// what the save order is about. + fn script(t: &TabChangeTracker, columns: &[ColumnInfo]) -> Vec { + let tracked = t.change_set(columns).unwrap(); + tracked + .changes + .changes + .iter() + .enumerate() + .map(|(index, change)| match (change, tracked.source(index)) { + (RowChange::Insert { .. }, Some(StatementSource::Insert { draft_id })) => format!("insert {draft_id}"), + (RowChange::Update(update), _) => format!("update {:?}", update.key), + (RowChange::Delete { key }, _) => format!("delete {key:?}"), + (change, source) => panic!("{change:?} came from {source:?}"), + }) + .collect() + } + + #[test] + fn undo_after_a_b_c_restores_b() { + let mut t = TabChangeTracker::default(); + edit(&mut t, 1, "a", "b"); + edit(&mut t, 1, "a", "c"); + + let undone = t.undo(); + + assert_eq!(undone.map(|delta| delta.rows), Some(vec![rk(&[Value::Int(1)])])); + assert_eq!(pending(&t, 1), text("b")); + assert_eq!(t.cell_state(&rk(&[Value::Int(1)]), 1), CellState::Modified); + assert_eq!(t.pending_count(), 1); + } + #[test] - fn change_set_orders_inserts_then_updates_then_deletes() { + fn undo_after_a_b_a_restores_b() { + let mut t = TabChangeTracker::default(); + edit(&mut t, 1, "a", "b"); + edit(&mut t, 1, "a", "a"); + assert_eq!(t.pending_count(), 0); + + let _ = t.undo(); + + assert_eq!(pending(&t, 1), text("b")); + assert_eq!(t.pending_count(), 1); + } + + #[test] + fn redo_after_undo_reapplies_c() { + let mut t = TabChangeTracker::default(); + edit(&mut t, 1, "a", "b"); + edit(&mut t, 1, "a", "c"); + let _ = t.undo(); + + let redone = t.redo(); + + assert_eq!(redone.map(|delta| delta.pending_count), Some(1)); + assert_eq!(pending(&t, 1), text("c")); + let _ = t.undo(); + let _ = t.undo(); + assert_eq!(pending(&t, 1), text("a")); + assert_eq!(t.pending_count(), 0); + } + + #[test] + fn new_edit_after_undo_clears_redo() { + let mut t = TabChangeTracker::default(); + edit(&mut t, 1, "a", "b"); + edit(&mut t, 1, "a", "c"); + let _ = t.undo(); + + edit(&mut t, 1, "a", "d"); + + assert!(!t.can_redo()); + assert!(t.redo().is_none()); + assert_eq!(pending(&t, 1), text("d")); + let _ = t.undo(); + assert_eq!(pending(&t, 1), text("b")); + } + + #[test] + fn undo_delete_restores_prior_mark_absence() { + let mut t = TabChangeTracker::default(); + let marked_twice = rk(&[Value::Int(1)]); + let marked_once = rk(&[Value::Int(2)]); + t.track_delete(marked_twice.clone(), vec![Value::Int(1)]); + t.track_delete(marked_twice.clone(), vec![Value::Int(1)]); + t.track_delete(marked_once.clone(), vec![Value::Int(2)]); + + let _ = t.undo(); + assert_eq!(t.row_state(&marked_once), RowState::Clean); + let _ = t.undo(); + assert_eq!(t.row_state(&marked_twice), RowState::PendingDelete); + let _ = t.undo(); + assert_eq!(t.row_state(&marked_twice), RowState::Clean); + assert_eq!(t.pending_count(), 0); + } + + #[test] + fn draft_cell_undo_restores_previous_draft_value() { + let mut t = TabChangeTracker::default(); + let draft_id = insert(&mut t, vec![Value::Null, text("a")]); + let _ = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 1, + value: text("b"), + }); + let _ = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 1, + value: text("c"), + }); + + let undone = t.undo(); + + assert_eq!( + undone.map(|delta| delta.visual), + Some(vec![VisualChange::SetDraftCell { + draft_id, + col: 1, + value: text("b") + }]) + ); + assert_eq!(t.drafts()[0].values[1], text("b")); + let redone = t.redo().map(|delta| delta.visual); + assert_eq!( + redone, + Some(vec![VisualChange::SetDraftCell { + draft_id, + col: 1, + value: text("c") + }]) + ); + } + + #[test] + fn change_set_orders_rows_by_last_touch() { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("name")]; - let updated = rk(&[Value::Int(5)]); - t.track_cell_edit(updated.clone(), 1, Value::Text("old".into()), Value::Text("new".into())); - t.track_insert(vec![Value::Null, Value::Text("draft".into())]); - t.track_delete(rk(&[Value::Int(9)]), vec![Value::Int(9), Value::Text("doomed".into())]); + edit(&mut t, 1, "a", "b"); + let draft_id = insert(&mut t, vec![Value::Null, text("draft")]); + t.track_delete(rk(&[Value::Int(3)]), vec![Value::Int(3)]); + edit(&mut t, 2, "a", "b"); + // Row 1 moves behind everything it was ahead of. + edit(&mut t, 1, "a", "c"); + + assert_eq!( + script(&t, &columns), + vec![ + format!("insert {draft_id}"), + "delete [Int(3)]".to_owned(), + "update [Int(2)]".to_owned(), + "update [Int(1)]".to_owned(), + ] + ); + + // Undo puts the row's touch back with its value. + let _ = t.undo(); + assert_eq!(script(&t, &columns)[0], "update [Int(1)]"); + } + + #[test] + fn change_set_places_delete_before_later_insert() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("id"), data_col("email")]; + t.track_delete(rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + let draft_id = insert(&mut t, vec![Value::Null, text("a")]); let (tracked, plan) = plan(&t, &columns); let sql: Vec<&str> = plan.batch.steps.iter().map(|step| step.statement.sql()).collect(); - assert_eq!(sql.len(), 3); - assert!(sql[0].starts_with("INSERT"), "{sql:?}"); - assert!(sql[1].starts_with("UPDATE"), "{sql:?}"); - assert!(sql[2].starts_with("DELETE"), "{sql:?}"); - let sources: Vec<_> = (0..3) - .map(|step| plan.origin(step).and_then(|o| tracked.source(o))) + assert!(sql[0].starts_with("DELETE"), "{sql:?}"); + assert!(sql[1].starts_with("INSERT"), "{sql:?}"); + assert!(matches!( + plan.origin(1).and_then(|change| tracked.source(change)), + Some(StatementSource::Insert { draft_id: id }) if id == draft_id + )); + } + + #[test] + fn a_draft_edited_after_a_delete_saves_after_it() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("id"), data_col("email")]; + let draft_id = insert(&mut t, vec![Value::Null, Value::Null]); + t.track_delete(rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + let _ = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 1, + value: text("a"), + }); + + assert_eq!( + script(&t, &columns), + vec!["delete [Int(1)]".to_owned(), format!("insert {draft_id}")] + ); + } + + #[test] + fn change_set_identical_across_trackers() { + let columns = vec![pk_col("id"), data_col("name")]; + // Every tracker's maps get their own hash seed, so with this + // many rows two trackers iterate them in different orders. + let edit_script = |t: &mut TabChangeTracker| { + for id in (0..40).rev() { + match id % 3 { + 0 => t.track_delete(rk(&[Value::Int(id)]), vec![Value::Int(id)]), + _ => t.track_cell_edit(rk(&[Value::Int(id)]), 1, text("a"), text("b")), + }; + } + let _ = t.track_insert(vec![Value::Null, text("draft")]); + }; + let mut first = TabChangeTracker::default(); + let mut second = TabChangeTracker::default(); + edit_script(&mut first); + edit_script(&mut second); + + let mut edit_order: Vec = (0..40) + .rev() + .map(|id| match id % 3 { + 0 => format!("delete [Int({id})]"), + _ => format!("update [Int({id})]"), + }) .collect(); - assert!(matches!(sources[0], Some(StatementSource::Insert { .. }))); - assert!(matches!(&sources[1], Some(StatementSource::Update { row_key }) if *row_key == updated)); - assert!(matches!(sources[2], Some(StatementSource::Delete { .. }))); + edit_order.push("insert 0".to_owned()); + + assert_eq!(script(&first, &columns), edit_order); + assert_eq!(script(&second, &columns), edit_order); + } + + #[test] + fn deleted_row_emits_only_delete() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("id"), data_col("name")]; + edit(&mut t, 1, "a", "b"); + t.track_delete(rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + + assert_eq!(script(&t, &columns), vec!["delete [Int(1)]".to_owned()]); + + // Undoing the delete brings the edit back into the save. + let _ = t.undo(); + assert_eq!(script(&t, &columns), vec!["update [Int(1)]".to_owned()]); } #[test] diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index ff92e24c3f..0379f9b149 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -17,8 +17,10 @@ pub mod structure_tracker_handle; pub mod structure_tracker_registry; pub mod tab_registry; pub mod tab_tracker_handle; +pub mod touch; pub mod tracker_delta; pub mod tracker_edit; +pub mod undo_op; pub mod workspace_state; static PATHS: OnceLock = OnceLock::new(); diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs index b30b133d5d..b3bf902f5c 100644 --- a/linux/crates/app/src/services/tab_tracker_handle.rs +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -98,6 +98,29 @@ mod tests { assert_eq!(tracker.pending_count(), 2); } + #[test] + fn undo_rebinds_the_row_with_the_previous_pending_value() { + let tracker = TabTrackerHandle::default(); + let fetched = Value::Text("a".into()); + for value in ["b", "c"] { + let _ = tracker.apply(TrackerEdit::Cell { + row_key: row(1), + col: 1, + original: fetched.clone(), + value: Value::Text(value.into()), + }); + } + + let undone = tracker.undo().expect("an edit to undo"); + + assert_eq!(undone.rows, vec![row(1)]); + assert_eq!(undone.pending_count, 1); + assert_eq!( + tracker.cell_overlay(&row(1), 1, &fetched).value, + Value::Text("b".into()) + ); + } + #[test] fn clones_share_one_tracker() { let tracker = TabTrackerHandle::default(); diff --git a/linux/crates/app/src/services/touch.rs b/linux/crates/app/src/services/touch.rs new file mode 100644 index 0000000000..bc783a472e --- /dev/null +++ b/linux/crates/app/src/services/touch.rs @@ -0,0 +1,12 @@ +/// When a pending change was made, from a counter its tracker owns. A +/// save runs its rows in touch order, so the statements follow the +/// edits rather than the order a hash map happens to iterate in. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Touch(u64); + +impl Touch { + #[must_use] + pub fn next(self) -> Self { + Touch(self.0 + 1) + } +} diff --git a/linux/crates/app/src/services/undo_op.rs b/linux/crates/app/src/services/undo_op.rs new file mode 100644 index 0000000000..da5e6ad509 --- /dev/null +++ b/linux/crates/app/src/services/undo_op.rs @@ -0,0 +1,46 @@ +use tablepro_core::Value; + +use super::change_tracker::{CellEdit, DeleteMark, DraftRow, RowKey}; +use super::touch::Touch; + +/// One step of a tab's undo history, holding the pending state on both +/// sides of the step. Undo puts `before` back exactly and redo puts +/// `after` back, touches included, so a save after an undo runs in the +/// same order it would have before the step. +#[derive(Debug, Clone, PartialEq)] +pub enum UndoOp { + /// `None` on either side means the cell had no pending edit, which + /// is also what an edit back to the fetched value leaves. + CellEdit { + row_key: RowKey, + col: usize, + before: Option, + after: Option, + }, + DraftCell { + draft_id: u64, + col: usize, + before: Value, + after: Value, + before_touch: Touch, + after_touch: Touch, + }, + Insert { + draft: DraftRow, + }, + Delete { + row_key: RowKey, + before: Option, + after: DeleteMark, + }, +} + +impl UndoOp { + pub fn is_about_draft(&self, draft_id: u64) -> bool { + match self { + UndoOp::Insert { draft } => draft.draft_id == draft_id, + UndoOp::DraftCell { draft_id: id, .. } => *id == draft_id, + UndoOp::CellEdit { row_key, .. } | UndoOp::Delete { row_key, .. } => *row_key == RowKey::Draft(draft_id), + } + } +} diff --git a/linux/crates/app/tests/tracker_save_postgres.rs b/linux/crates/app/tests/tracker_save_postgres.rs new file mode 100644 index 0000000000..4c4582ecfe --- /dev/null +++ b/linux/crates/app/tests/tracker_save_postgres.rs @@ -0,0 +1,131 @@ +//! A browse tab's pending edits saved to a real PostgreSQL, planned the +//! way the app plans them. + +#![expect( + clippy::expect_used, + reason = "the setup helpers fail a test the way the test functions themselves do" +)] + +use std::sync::Arc; +use std::time::Duration; + +use drivers_postgres::PgDriver; +use secrecy::SecretString; +use tablepro_app::change_tracker::{RowKey, pk_identity}; +use tablepro_app::tab_tracker_handle::TabTrackerHandle; +use tablepro_app::tracker_edit::TrackerEdit; +use tablepro_core::meta::TableRef; +use tablepro_core::save::plan_save; +use tablepro_core::statement::{GuardedStatement, Statement, StatementBuilder}; +use tablepro_core::{ + CallOptions, ConnectEnvironment, ConnectOptions, ConnectTarget, Connection, Credentials, DatabaseDriver, + ForeignKeyMode, NetworkEndpoint, NetworkTarget, RowLimit, TlsConfig, Value, WorkspaceSettings, WriteBatch, + WriteKind, +}; +use tablepro_net::DirectTcpTransport; +use tablepro_test_fixtures::{HbaMode, PostgresFixture}; +use tablepro_test_support::conformance::test_liveness; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +const CALL: Duration = Duration::from_secs(30); + +fn within() -> CallOptions { + CallOptions::new(Some(Instant::now() + CALL), CancellationToken::new()) +} + +fn statement(sql: &str) -> Statement { + let mut builder = StatementBuilder::new(); + builder.push_sql(sql); + builder.finish() +} + +async fn connect(fixture: &PostgresFixture) -> Arc { + let liveness = test_liveness(); + let endpoint = NetworkEndpoint::new(fixture.host(), fixture.port()).expect("a valid endpoint"); + let credentials = fixture.password_credentials(); + let options = ConnectOptions { + target: ConnectTarget::Network(NetworkTarget { + transport: Arc::new(DirectTcpTransport::new(endpoint, &liveness)), + credentials: Credentials::Password { + username: credentials.username.clone(), + password: SecretString::from(credentials.password.clone()), + }, + database: Some(fixture.database().to_owned()), + tls: TlsConfig::disabled(), + }), + workspace_settings: WorkspaceSettings::workspace(false), + liveness, + environment: ConnectEnvironment::new("test"), + }; + PgDriver + .connect(options, CancellationToken::new()) + .await + .expect("a connection") +} + +async fn setup(connection: &dyn Connection, statements: &[&str]) { + let steps = statements + .iter() + .map(|sql| GuardedStatement::unguarded(statement(sql))) + .collect(); + let batch = WriteBatch::new( + WriteKind::SchemaChanges, + TableRef::unqualified("users"), + steps, + ForeignKeyMode::Enforced, + ); + connection + .write_batch(&batch, within()) + .await + .expect("the setup statements"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn replace_by_unique_key_saves() { + let fixture = PostgresFixture::start(HbaMode::Password) + .await + .expect("a PostgreSQL container"); + let connection = connect(&fixture).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE users (id integer PRIMARY KEY, email text NOT NULL UNIQUE)", + "INSERT INTO users VALUES (1, 'a')", + ], + ) + .await; + let table = TableRef::new(Some("public".to_owned()), "users"); + let columns = connection.fetch_columns(&table, within()).await.expect("the columns"); + let tracker = TabTrackerHandle::default(); + let _ = tracker.apply(TrackerEdit::Delete { + row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + original_values: vec![Value::Int(1), Value::Text("a".to_owned())], + }); + let _ = tracker.apply(TrackerEdit::Insert { + values: vec![Value::Int(2), Value::Text("a".to_owned())], + }); + + let changes = tracker.change_set(&columns).expect("a change set"); + let plan = plan_save( + connection.dialect().as_ref(), + &table, + &columns, + &pk_identity(&columns), + &changes.changes, + ) + .expect("a save plan"); + connection.write_batch(&plan.batch, within()).await.expect("the save"); + + let saved = connection + .query_bound( + &statement("SELECT id FROM users WHERE email = 'a'"), + RowLimit::EDITOR_DEFAULT, + within(), + ) + .await + .expect("a read") + .rows; + assert_eq!(saved, vec![vec![Value::Int(2)]]); +} From 2dc6a3a3c796acc1ec4e14048b67177f3cfa426d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:12:30 +0700 Subject: [PATCH 061/178] ci(linux): run the app's docker tests in the GTK image --- .github/workflows/build-linux.yml | 62 +++++++++++++++++++++++++++++++ linux/docs/testing.md | 2 +- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 8ddd2ba9c9..27aed09747 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -274,3 +274,65 @@ jobs: with: name: junit-tablepro-session path: linux/target/nextest/ci/junit.xml + + integration-app: + # The app links GTK 4.22 and libadwaita 1.9, which the host runner + # cannot build, so this runs in the fast job's image like the + # session job. Only the named test targets run: the app's startup + # test needs Meson and the fast job already covers it. + name: Docker tests (tablepro) + runs-on: ubuntu-24.04 + needs: fast + container: + image: ubuntu:26.04 + env: + DEBIAN_FRONTEND: noninteractive + options: -v /var/run/docker.sock:/var/run/docker.sock + defaults: + run: + working-directory: linux + steps: + - name: Install git + run: | + apt-get update + apt-get install -y --no-install-recommends ca-certificates git + git config --global --add safe.directory "$GITHUB_WORKSPACE" + working-directory: / + - uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'schedule' && 'linux' || github.ref }} + - name: Install system dependencies + # The -dev packages the app links, as in the fast job. + run: | + apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + pkgconf \ + libgtk-4-dev \ + libadwaita-1-dev \ + libgtksourceview-5-dev \ + libsqlite3-dev \ + libkrb5-dev \ + clang \ + libclang-dev + - uses: dtolnay/rust-toolchain@ce678459e9fc7500d337468f904b95f1b5c10b5e # 1.98 + with: + toolchain: "1.98" + - uses: Swatinem/rust-cache@v2 + with: + workspaces: linux + key: tablepro-app + - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 + with: + tool: cargo-nextest@0.9.144 + - name: Verify docker is available + # No Docker CLI in the image; the API answers on the socket. + run: curl --fail --silent --show-error --unix-socket /var/run/docker.sock http://localhost/_ping + - name: Docker tests + run: cargo nextest run --locked --profile ci -p tablepro --test tracker_save_postgres --run-ignored only + - name: Upload JUnit report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: junit-tablepro + path: linux/target/nextest/ci/junit.xml diff --git a/linux/docs/testing.md b/linux/docs/testing.md index 3a4a693c7d..9810ace0c8 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -224,7 +224,7 @@ There is no app-level end-to-end test yet. Driving the GTK app under `xvfb-run` GitHub Actions (`.github/workflows/build-linux.yml`), Ubuntu runner: 1. **Fast checks**: `meson setup`, `cargo fmt --all -- --check`, clippy with and without default features, `meson compile`, `xvfb-run -a dbus-run-session -- meson test --print-errorlogs`, a Secret Service step under `gnome-keyring-daemon`, then `meson dist`. Runs in an `ubuntu:26.04` container, which ships GLib 2.88 plus the meson, appstream and desktop-file-utils packages. [CONTRIBUTING.md](../CONTRIBUTING.md#fast-job-commands) lists the same commands. -2. **Docker tests**: runs after fast checks pass. One matrix entry per package with docker tests (the PostgreSQL, MySQL, SQL Server and ClickHouse drivers, `tablepro-ssh` against an OpenSSH server container, and `tablepro-test-fixtures` for the fixtures themselves) runs that package's ignored docker tests through cargo-nextest on the host runner's Docker. `tablepro-session` builds storage, which needs GLib 2.88, so its job runs in the fast job's `ubuntu:26.04` image with the host's Docker socket mounted. +2. **Docker tests**: runs after fast checks pass. One matrix entry per package with docker tests (the PostgreSQL, MySQL, SQL Server and ClickHouse drivers, `tablepro-ssh` against an OpenSSH server container, and `tablepro-test-fixtures` for the fixtures themselves) runs that package's ignored docker tests through cargo-nextest on the host runner's Docker. `tablepro-session` builds storage, which needs GLib 2.88, so its job runs in the fast job's `ubuntu:26.04` image with the host's Docker socket mounted. The app's docker tests (`crates/app/tests/tracker_save_postgres.rs`) run the same way, since the app links GTK; that job names its test targets, because the app's `startup` test needs Meson. A Translations job also checks `po/POTFILES.in` and extracts the POT template. PRs only merge when every job is green. From 22f3df5f24297c9532150712c8ed3e5e52f0fae8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:14:18 +0700 Subject: [PATCH 062/178] fix(linux-editor): drop a closed editor's theme and font handlers --- linux/crates/app/src/ui/editor/imp.rs | 5 ++ linux/crates/app/src/ui/editor/mod.rs | 98 +++++++++++++++--------- linux/crates/app/src/ui/editor/tests.rs | 64 ++++++++++++++++ linux/crates/app/src/ui/handler_guard.rs | 66 ++++++++++++++++ linux/crates/app/src/ui/mod.rs | 1 + 5 files changed, 196 insertions(+), 38 deletions(-) create mode 100644 linux/crates/app/src/ui/handler_guard.rs diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index d1e29d6272..b506ba6edb 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -14,6 +14,7 @@ use super::editor_session_slot::EditorSessionSlot; use super::results_pane::ResultsPane; use super::run_log::RunLog; use crate::ui::grid::GridMsg; +use crate::ui::handler_guard::HandlerGuard; /// A run in flight: its cancel switch, and the loop that brings its /// results to the GTK thread. @@ -48,6 +49,9 @@ pub struct SqlEditor { /// Whether the last run left a transaction open, as last reported. pub transaction_dirty: Cell, pub shut_down: Cell, + /// Theme and font handlers on the app-wide style manager and + /// settings, which would otherwise outlive the tab. + pub(super) app_handlers: RefCell>, } #[glib::object_subclass] @@ -97,6 +101,7 @@ impl ObjectImpl for SqlEditor { fn dispose(&self) { self.obj().shutdown(); + self.app_handlers.take(); } } diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 92020be2a4..d229e43e15 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -41,6 +41,7 @@ use self::run_log::{RunEvent, RunLog}; use self::run_scope::ScopedText; use self::script_runner::{ScriptRun, plan_batches, run_in_tab}; use super::grid::GridMsg; +use super::handler_guard::HandlerGuard; use crate::services::active_session::ActiveSession; glib::wrapper! { @@ -493,32 +494,43 @@ impl SqlEditor { let view = self.imp().source_view.get(); view.set_monospace(true); crate::ui::style_scheme_resolver::apply(&view, &settings.style_scheme()); - let view_for_theme = view.downgrade(); - let settings_for_theme = settings.clone(); - adw::StyleManager::default().connect_dark_notify(move |_| { - if let Some(view) = view_for_theme.upgrade() { - crate::ui::style_scheme_resolver::apply(&view, &settings_for_theme.style_scheme()); + let style_manager = adw::StyleManager::default(); + let settings_for_theme = Rc::downgrade(settings); + let dark = style_manager.connect_dark_notify(glib::clone!( + #[weak] + view, + move |_| { + if let Some(settings) = settings_for_theme.upgrade() { + crate::ui::style_scheme_resolver::apply(&view, &settings.style_scheme()); + } } - }); - let view_for_scheme = view.downgrade(); - settings.gio().connect_changed( + )); + let scheme = settings.gio().connect_changed( Some(tablepro_storage::settings::keys::STYLE_SCHEME), - move |settings, key| { - if let Some(view) = view_for_scheme.upgrade() { - crate::ui::style_scheme_resolver::apply(&view, &settings.string(key)); - } - }, + glib::clone!( + #[weak] + view, + move |settings, key| crate::ui::style_scheme_resolver::apply(&view, &settings.string(key)) + ), ); + let mut handlers = vec![ + HandlerGuard::new(&style_manager, dark), + HandlerGuard::new(settings.gio(), scheme), + ]; crate::ui::editor_font_provider::apply(settings); for key in [ tablepro_storage::settings::keys::USE_SYSTEM_FONT, tablepro_storage::settings::keys::CUSTOM_FONT, ] { - let settings_for_key = settings.clone(); - settings.gio().connect_changed(Some(key), move |_, _| { - crate::ui::editor_font_provider::apply(&settings_for_key); + let settings_for_key = Rc::downgrade(settings); + let font = settings.gio().connect_changed(Some(key), move |_, _| { + if let Some(settings) = settings_for_key.upgrade() { + crate::ui::editor_font_provider::apply(&settings); + } }); + handlers.push(HandlerGuard::new(settings.gio(), font)); } + self.imp().app_handlers.replace(handlers); } fn connect_completion(&self, schema_buffer: >k4::TextBuffer, buffer: &sourceview5::Buffer) { @@ -531,18 +543,12 @@ impl SqlEditor { fn connect_buffer(&self, buffer: &sourceview5::Buffer) { let imp = self.imp(); let cursor_info = imp.cursor_info.get(); - let update_cursor = move |buffer: &sourceview5::Buffer| { - let iter = buffer.iter_at_mark(&buffer.get_insert()); - cursor_info.set_label(&crate::i18n::gettext_f( - "Ln {line}, Col {column}", - &[ - ("line", &(iter.line() + 1).to_string()), - ("column", &(iter.line_offset() + 1).to_string()), - ], - )); - }; - update_cursor(buffer); - buffer.connect_cursor_position_notify(move |buffer| update_cursor(buffer)); + show_cursor_position(&cursor_info, buffer); + buffer.connect_cursor_position_notify(glib::clone!( + #[weak] + cursor_info, + move |buffer| show_cursor_position(&cursor_info, buffer) + )); let weak = self.downgrade(); buffer.connect_changed(move |buffer| { @@ -552,16 +558,11 @@ impl SqlEditor { }); let run_button = imp.run_button.get(); - let update_label = move |buffer: &sourceview5::Buffer| { - if buffer.has_selection() { - run_button.set_label(&crate::i18n::gettext("Run Selection")); - run_button.set_tooltip_text(Some(&crate::i18n::gettext("Run the selected text (Ctrl+Return)"))); - } else { - run_button.set_label(&crate::i18n::gettext("Run")); - run_button.set_tooltip_text(Some(&crate::i18n::gettext("Run the script (Ctrl+Return)"))); - } - }; - buffer.connect_has_selection_notify(move |buffer| update_label(buffer)); + buffer.connect_has_selection_notify(glib::clone!( + #[weak] + run_button, + move |buffer| label_run_button(&run_button, buffer.has_selection()) + )); } fn connect_buttons(&self) { @@ -746,6 +747,27 @@ impl SqlEditor { } } +fn show_cursor_position(label: >k4::Label, buffer: &sourceview5::Buffer) { + let iter = buffer.iter_at_mark(&buffer.get_insert()); + label.set_label(&crate::i18n::gettext_f( + "Ln {line}, Col {column}", + &[ + ("line", &(iter.line() + 1).to_string()), + ("column", &(iter.line_offset() + 1).to_string()), + ], + )); +} + +fn label_run_button(button: >k4::Button, has_selection: bool) { + if has_selection { + button.set_label(&crate::i18n::gettext("Run Selection")); + button.set_tooltip_text(Some(&crate::i18n::gettext("Run the selected text (Ctrl+Return)"))); + } else { + button.set_label(&crate::i18n::gettext("Run")); + button.set_tooltip_text(Some(&crate::i18n::gettext("Run the script (Ctrl+Return)"))); + } +} + fn buffer_text(buffer: &impl IsA) -> String { let buffer = buffer.as_ref(); let (start, end) = buffer.bounds(); diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 8db240ce2f..06026c6613 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -303,6 +303,70 @@ fn the_icon_buttons_have_accessible_names() { )); } +fn unconnected_editor(settings: &MemorySettings, tasks: Tasks) -> SqlEditor { + crate::register_test_resources(); + SqlEditor::new(SqlEditorInit { + schema_buffer: build_schema_buffer(), + initial_query: None, + settings: settings.get().clone(), + history: None, + tasks, + session: ActiveSession::new(), + hold: Rc::new(tablepro_session::runtime::CountingHold::new()), + close_timeout: Duration::from_secs(5), + }) +} + +fn scheme_id(view: &sourceview5::View) -> Option { + let buffer = view.buffer().downcast::().ok()?; + buffer.style_scheme().map(|scheme| scheme.id().to_string()) +} + +#[gtk4::test] +fn closed_editor_frees_source_view() { + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let editor = unconnected_editor(&settings, runtime.tasks()); + let view = editor.imp().source_view.get().downgrade(); + + drop(editor); + crate::test_support::drain_main_context(); + + assert!( + view.upgrade().is_none(), + "the closed editor's source view is still alive" + ); +} + +#[gtk4::test] +fn closed_editor_drops_its_theme_and_font_handlers() { + let _ = adw::init(); + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let counts = |settings: &MemorySettings| (Rc::strong_count(settings.get()), Rc::weak_count(settings.get())); + let before = counts(&settings); + let style = adw::StyleManager::default(); + style.set_color_scheme(adw::ColorScheme::ForceLight); + let editor = unconnected_editor(&settings, runtime.tasks()); + // Held past the editor, so a handler left behind has a view to repaint. + let view = editor.imp().source_view.get(); + let scheme = scheme_id(&view); + + drop(editor); + crate::test_support::drain_main_context(); + settings.get().set_style_scheme("classic").expect("a style scheme"); + style.set_color_scheme(adw::ColorScheme::ForceDark); + let after = scheme_id(&view); + style.set_color_scheme(adw::ColorScheme::Default); + + assert_eq!(after, scheme, "a closed editor still follows the theme"); + assert_eq!( + counts(&settings), + before, + "the settings still carry a closed editor's handlers" + ); +} + #[test] fn export_name_slugs_the_statement() { assert_eq!(export_name_for_query("SELECT * FROM users"), "select-from-users"); diff --git a/linux/crates/app/src/ui/handler_guard.rs b/linux/crates/app/src/ui/handler_guard.rs new file mode 100644 index 0000000000..f9befd868c --- /dev/null +++ b/linux/crates/app/src/ui/handler_guard.rs @@ -0,0 +1,66 @@ +use gtk4::glib; +use gtk4::glib::prelude::*; + +/// A handler on an object that outlives the widget that connected it, +/// such as the style manager or the settings. The object never drops +/// the handler on its own, so the widget holds this and the handler +/// goes with it. +pub(crate) struct HandlerGuard { + object: glib::WeakRef, + handler: Option, +} + +impl HandlerGuard { + pub(crate) fn new(object: &impl IsA, handler: glib::SignalHandlerId) -> Self { + Self { + object: object.upcast_ref::().downgrade(), + handler: Some(handler), + } + } +} + +impl Drop for HandlerGuard { + fn drop(&mut self) { + if let (Some(object), Some(handler)) = (self.object.upgrade(), self.handler.take()) { + object.disconnect(handler); + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use gtk4::gio; + use gtk4::gio::prelude::*; + + use super::*; + + #[test] + fn dropping_the_guard_disconnects_the_handler() { + let action = gio::SimpleAction::new("probe", None); + let calls = Rc::new(Cell::new(0)); + let counter = calls.clone(); + let guard = HandlerGuard::new( + &action, + action.connect_enabled_notify(move |_| counter.set(counter.get() + 1)), + ); + action.set_enabled(false); + assert_eq!(calls.get(), 1); + + drop(guard); + action.set_enabled(true); + + assert_eq!(calls.get(), 1, "the handler ran after its guard dropped"); + } + + #[test] + fn a_guard_outliving_its_object_drops_quietly() { + let action = gio::SimpleAction::new("probe", None); + let guard = HandlerGuard::new(&action, action.connect_enabled_notify(|_| {})); + + drop(action); + drop(guard); + } +} diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 967546fe4e..4b94e71a64 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod error_text; mod export_dialog; mod filter_strip; mod grid; +mod handler_guard; mod history_dialog; pub(crate) mod icons; mod import_dialog; From d778960fb53df5d9be9842c8983c76227c156651 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:14:25 +0700 Subject: [PATCH 063/178] fix(linux-structure): free a closed structure tab and its theme handler --- .../app/src/ui/structure_tab/columns.rs | 18 ++- linux/crates/app/src/ui/structure_tab/mod.rs | 150 +++++++++++++----- 2 files changed, 122 insertions(+), 46 deletions(-) diff --git a/linux/crates/app/src/ui/structure_tab/columns.rs b/linux/crates/app/src/ui/structure_tab/columns.rs index 33fe4af024..c07286b8ed 100644 --- a/linux/crates/app/src/ui/structure_tab/columns.rs +++ b/linux/crates/app/src/ui/structure_tab/columns.rs @@ -193,13 +193,16 @@ pub(super) fn build_column_expander_row( name_row.set_widget_name(&format!("col-name-{index}")); let sender_for_name = sender.clone(); let suppress_for_name = suppress_emit.clone(); - let row_for_name = row.clone(); + // The row owns this entry, so the entry holds the row weakly. + let row_for_name = row.downgrade(); name_row.connect_changed(move |e| { if suppress_for_name.get() { return; } let text = e.text().to_string(); - row_for_name.set_title(&glib::markup_escape_text(&text)); + if let Some(row) = row_for_name.upgrade() { + row.set_title(&glib::markup_escape_text(&text)); + } sender_for_name.input(StructureTabInput::ColumnEdited { index, field: ColumnField::Name(text), @@ -376,18 +379,17 @@ pub(super) fn build_column_expander_row( /// collide on the action name. /// /// Returns the button plus the auto-built PopoverMenu so the caller -/// can register it for popdown on rebuild — otherwise an open menu -/// would keep its captured target alive and dispatch a click into a -/// detached AdwEntryRow. +/// can pop it down before the row goes. fn build_type_suggestions_button(driver_id: &str, target: &adw::EntryRow) -> (gtk::MenuButton, gtk::Popover) { // Per-button action group: one action `apply` keyed by `String` // parameter. Each menu item activates `types.apply::`. let action_group = gio::SimpleActionGroup::new(); let apply_action = gio::SimpleAction::new("apply", Some(&String::static_variant_type())); - let target_for_action = target.clone(); + // The entry owns this button and its actions. + let target_for_action = target.downgrade(); apply_action.connect_activate(move |_, param| { - if let Some(s) = param.and_then(|v| v.get::()) { - target_for_action.set_text(&s); + if let (Some(s), Some(target)) = (param.and_then(|v| v.get::()), target_for_action.upgrade()) { + target.set_text(&s); } }); action_group.add_action(&apply_action); diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index 1a6b3daeae..86bc7e00f6 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -44,6 +44,7 @@ use tablepro_core::sql_ddl::{BuildDdlError, DraftColumn}; use tablepro_core::{ForeignKeyInfo, IndexInfo}; use crate::services::structure_tracker_handle::StructureTrackerHandle; +use crate::ui::handler_guard::HandlerGuard; use crate::ui::structure_tab_dialogs::{present_fk_dialog, present_index_dialog}; use tablepro_core::sql_ddl::{StructureOp, diff_to_ops, materialize_ops}; @@ -142,14 +143,9 @@ pub struct StructureTab { /// confusing UX is worse than the no-op rename the user has to /// do afterwards). next_column_seq: Rc>, - /// Popovers attached to column rows (currently only the type - /// suggestions popover). Each `rebuild_columns_view` call must - /// `popdown` and clear this list before `clear_box`, otherwise an - /// open popover keeps a strong reference to the now-detached - /// AdwEntryRow and a click on a suggestion `set_text`s a stale - /// widget. The detached entry's `changed` handler then dispatches - /// `ColumnEdited` with whatever index was captured at build time — - /// which may now point at a different (or nonexistent) column. + /// The type suggestion popovers of the column rows. A rebuild pops + /// them down before it removes the rows, so no menu stays open over + /// a row that is gone. column_popovers: Rc>>, /// True between `SaveCompleted` and the matching `StructureLoaded` /// (or `LoadFailed`). During this window the live model has been @@ -161,6 +157,9 @@ pub struct StructureTab { /// flag is set; `StructureLoaded` flips it back and triggers a /// fresh recompute against the up-to-date snapshots. refetching: Rc>, + /// The SQL preview follows the desktop theme through the app-wide + /// style manager, which would keep the handler past the tab. + _theme_handler: HandlerGuard, } #[derive(Debug)] @@ -340,12 +339,6 @@ impl StructureTab { // We re-enable emit on the next idle tick so legitimate user // input afterwards flows through. self.suppress_emit.set(true); - // Popdown + drop any popovers attached to the previous rows - // before they're unparented. An open suggestions popover holds - // the old AdwEntryRow alive via a closure clone; without this - // step a click on a suggestion after a Refresh would `set_text` - // a detached widget and dispatch `ColumnEdited` with a stale - // index (see `column_popovers` doc on `StructureTab`). { let mut popovers = self.column_popovers.borrow_mut(); for p in popovers.drain(..) { @@ -394,13 +387,15 @@ impl StructureTab { } let columns_for_dialog = self.columns.clone(); let sender_for_add = sender.clone(); - let parent_box = self.indexes_box.clone(); + let parent_box = self.indexes_box.downgrade(); append_add_button(&list, &crate::i18n::gettext("Add Index…"), move || { - present_index_dialog( - parent_box.upcast_ref(), - &columns_for_dialog.borrow(), - sender_for_add.clone(), - ); + if let Some(parent_box) = parent_box.upgrade() { + present_index_dialog( + parent_box.upcast_ref(), + &columns_for_dialog.borrow(), + sender_for_add.clone(), + ); + } }); self.indexes_box.append(&list); } @@ -414,15 +409,17 @@ impl StructureTab { } let columns_for_dialog = self.columns.clone(); let sender_for_add = sender.clone(); - let parent_box = self.fks_box.clone(); + let parent_box = self.fks_box.downgrade(); let driver_id_for_dialog = driver_id.clone(); append_add_button(&list, &crate::i18n::gettext("Add Foreign Key…"), move || { - present_fk_dialog( - parent_box.upcast_ref(), - &columns_for_dialog.borrow(), - &driver_id_for_dialog, - sender_for_add.clone(), - ); + if let Some(parent_box) = parent_box.upgrade() { + present_fk_dialog( + parent_box.upcast_ref(), + &columns_for_dialog.borrow(), + &driver_id_for_dialog, + sender_for_add.clone(), + ); + } }); self.fks_box.append(&list); } @@ -573,14 +570,17 @@ impl SimpleComponent for StructureTab { sql_view.set_left_margin(6); sql_view.set_right_margin(6); sql_view.set_bottom_margin(6); - // Match the system light / dark scheme. Mirrors the editor.rs - // hook so the SQL preview's syntax colours track the user's - // theme choice instead of staying frozen on the boot scheme. apply_sql_scheme(&sql_buffer); - let buffer_for_theme = sql_buffer.clone(); - adw::StyleManager::default().connect_dark_notify(move |_| { - apply_sql_scheme(&buffer_for_theme); - }); + let style_manager = adw::StyleManager::default(); + let buffer_for_theme = sql_buffer.downgrade(); + let theme_handler = HandlerGuard::new( + &style_manager, + style_manager.connect_dark_notify(move |_| { + if let Some(buffer) = buffer_for_theme.upgrade() { + apply_sql_scheme(&buffer); + } + }), + ); let sql_scroll = gtk::ScrolledWindow::builder().child(&sql_view).vexpand(true).build(); // Copy SQL toolbar — saves the user from clicking into the // sourceview, Ctrl+A, Ctrl+C every time they want to paste @@ -728,12 +728,13 @@ impl SimpleComponent for StructureTab { retry_button.add_css_class("suggested-action"); retry_button.add_css_class("pill"); let sender_for_retry = sender.clone(); - let inner_stack_for_retry = inner_stack.clone(); + let inner_stack_for_retry = inner_stack.downgrade(); retry_button.connect_clicked(move |_| { - // Flip back to the loading page so the user sees we're - // trying — otherwise the click looks like a no-op until + // Without the loading page the click reads as a no-op until // StructureLoaded arrives. - inner_stack_for_retry.set_visible_child_name("loading"); + if let Some(inner_stack) = inner_stack_for_retry.upgrade() { + inner_stack.set_visible_child_name("loading"); + } let _ = sender_for_retry.output(StructureTabOutput::FetchStructure); }); error_status.set_child(Some(&retry_button)); @@ -839,6 +840,7 @@ impl SimpleComponent for StructureTab { next_column_seq: Rc::new(RefCell::new(1)), column_popovers: Rc::new(RefCell::new(Vec::new())), refetching: Rc::new(Cell::new(false)), + _theme_handler: theme_handler, }; // Initial render so New-mode tabs aren't blank. @@ -1135,3 +1137,75 @@ fn apply_sql_scheme(buffer: &sourceview5::Buffer) { buffer.set_style_scheme(Some(&scheme)); } } + +#[cfg(test)] +mod tests { + use relm4::{Component, ComponentController, Controller}; + + use super::*; + use crate::test_support::{drain_main_context, first_descendant_of_type}; + + fn new_table() -> Controller { + adw::init().expect("libadwaita initialises under the test backend"); + StructureTab::builder() + .launch(StructureTabInit { + tracker: StructureTrackerHandle::default(), + schema: None, + table: "t".to_owned(), + mode: StructureMode::New, + driver_id: "postgres".to_owned(), + defer_initial_fetch: false, + }) + .detach() + } + + fn scheme_id(buffer: &sourceview5::Buffer) -> Option { + buffer.style_scheme().map(|scheme| scheme.id().to_string()) + } + + #[gtk4::test] + fn style_handler_disconnected_on_shutdown() { + let style = adw::StyleManager::default(); + style.set_color_scheme(adw::ColorScheme::ForceLight); + let controller = new_table(); + let buffer = controller.model().sql_buffer.clone(); + let light = scheme_id(&buffer); + + drop(controller); + drain_main_context(); + style.set_color_scheme(adw::ColorScheme::ForceDark); + let after = scheme_id(&buffer); + style.set_color_scheme(adw::ColorScheme::Default); + + assert_eq!(after, light, "a closed structure tab still follows the theme"); + } + + #[gtk4::test] + fn structure_tab_freed_after_drop() { + let controller = new_table(); + controller.emit(StructureTabInput::AddColumn); + drain_main_context(); + let row = first_descendant_of_type::(controller.widget()) + .expect("the added column's row") + .downgrade(); + let widgets: Vec> = { + let model = controller.model(); + [ + model.inner_stack.upcast_ref::(), + model.indexes_box.upcast_ref(), + model.fks_box.upcast_ref(), + controller.widget().upcast_ref(), + ] + .into_iter() + .map(ObjectExt::downgrade) + .collect() + }; + + drop(controller); + drain_main_context(); + + assert!(row.upgrade().is_none(), "a column row outlived its tab"); + let alive = widgets.iter().filter(|widget| widget.upgrade().is_some()).count(); + assert_eq!(alive, 0, "parts of the structure tab outlived its controller"); + } +} From 93a91eaebf425fdaee2e3129d0b21727f6ffde1f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:14:26 +0700 Subject: [PATCH 064/178] fix(linux-grid): free columns and cells with their column view --- linux/crates/app/src/ui/grid.rs | 249 ++++++++++++++++++++++++++------ 1 file changed, 204 insertions(+), 45 deletions(-) diff --git a/linux/crates/app/src/ui/grid.rs b/linux/crates/app/src/ui/grid.rs index b06d4b68f7..ed1caa1c8f 100644 --- a/linux/crates/app/src/ui/grid.rs +++ b/linux/crates/app/src/ui/grid.rs @@ -530,10 +530,9 @@ fn build_column( // value the moment it changes. column.set_fixed_width(min); } - let column_for_save = column.clone(); let column_name = info.name.clone(); - column.connect_fixed_width_notify(move |_| { - widths.record(&column_name, column_for_save.fixed_width()); + column.connect_fixed_width_notify(move |column| { + widths.record(&column_name, column.fixed_width()); }); } else if let Some(min) = default_min_width { // Editor result grids run without a connection_id (no @@ -917,16 +916,21 @@ fn install_edit_triggers( // Double-click → trigger. Capture phase so we beat the ColumnView // row-selection gesture that runs in Bubble. + // + // The handlers read the cell from their controller: the cell owns + // both controllers, so capturing it would be a cycle. let gesture = gtk::GestureClick::builder().button(gtk::gdk::BUTTON_PRIMARY).build(); gesture.set_propagation_phase(gtk::PropagationPhase::Capture); - let label_for_press = label.clone(); let trigger_for_press = trigger.clone(); gesture.connect_pressed(move |gesture, n_press, _, _| { if n_press != 2 { return; } + let Some(label) = gesture.widget().and_downcast::() else { + return; + }; gesture.set_state(gtk::EventSequenceState::Claimed); - trigger_for_press(&label_for_press); + trigger_for_press(&label); }); label.add_controller(gesture); @@ -938,38 +942,40 @@ fn install_edit_triggers( // Tab / Shift+Tab commit + traverse. Return / Esc handled by // CellEditor's built-in entry handlers. let controller = gtk::EventControllerKey::new(); - let label_for_key = label.clone(); let trigger_for_key = trigger; - controller.connect_key_pressed(move |_, keyval, _, modifiers| { - let editing = label_for_key.is_editing(); + controller.connect_key_pressed(move |controller, keyval, _, modifiers| { + let Some(label) = controller.widget().and_downcast::() else { + return glib::Propagation::Proceed; + }; + let editing = label.is_editing(); let shift = modifiers.contains(gtk::gdk::ModifierType::SHIFT_MASK); if !editing { match keyval { gtk::gdk::Key::F2 | gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter => { - trigger_for_key(&label_for_key); + trigger_for_key(&label); return glib::Propagation::Stop; } // Tab / Shift+Tab when NOT editing: traverse cells. // Without this they'd fall through to GTK's default // focus chain and escape the grid to the paginator. gtk::gdk::Key::Tab if !shift => { - move_focus(&label_for_key, gtk::DirectionType::TabForward); + move_focus(&label, gtk::DirectionType::TabForward); return glib::Propagation::Stop; } gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab if shift => { - move_focus(&label_for_key, gtk::DirectionType::TabBackward); + move_focus(&label, gtk::DirectionType::TabBackward); return glib::Propagation::Stop; } // Left / Right arrow cell navigation. ColumnView's // built-in Up/Down handles row selection; we add the // horizontal axis to match spreadsheet convention. gtk::gdk::Key::Right => { - move_focus(&label_for_key, gtk::DirectionType::TabForward); + move_focus(&label, gtk::DirectionType::TabForward); return glib::Propagation::Stop; } gtk::gdk::Key::Left => { - move_focus(&label_for_key, gtk::DirectionType::TabBackward); + move_focus(&label, gtk::DirectionType::TabBackward); return glib::Propagation::Stop; } _ => {} @@ -979,13 +985,13 @@ fn install_edit_triggers( match keyval { gtk::gdk::Key::Tab if !shift => { - label_for_key.stop_editing(true); - move_focus(&label_for_key, gtk::DirectionType::TabForward); + label.stop_editing(true); + move_focus(&label, gtk::DirectionType::TabForward); glib::Propagation::Stop } gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab if shift => { - label_for_key.stop_editing(true); - move_focus(&label_for_key, gtk::DirectionType::TabBackward); + label.stop_editing(true); + move_focus(&label, gtk::DirectionType::TabBackward); glib::Propagation::Stop } _ => glib::Propagation::Proceed, @@ -1010,10 +1016,13 @@ fn show_calendar_popover(label: &super::cell_editor::CellEditor, col_index: usiz popover.set_parent(label); POPOVER_SLOT.set(label, popover.clone()); - let label_for_cal = label.clone(); - let popover_for_cal = popover.clone(); + let label_for_cal = label.downgrade(); + let popover_for_cal = popover.downgrade(); let sender_for_cal = sender.clone(); calendar.connect_day_selected(move |c| { + let (Some(label_for_cal), Some(popover_for_cal)) = (label_for_cal.upgrade(), popover_for_cal.upgrade()) else { + return; + }; let dt = c.date(); // glib::DateTime month is 1-based; chrono format directly. let formatted = format!("{:04}-{:02}-{:02}", dt.year(), dt.month(), dt.day_of_month()); @@ -1065,10 +1074,15 @@ fn show_spin_button_popover( popover.set_parent(label); POPOVER_SLOT.set(label, popover.clone()); - let label_for_commit = label.clone(); - let popover_for_commit = popover.clone(); + let label_for_commit = label.downgrade(); + let popover_for_commit = popover.downgrade(); let sender_for_commit = sender.clone(); spin.connect_activate(move |s| { + let (Some(label_for_commit), Some(popover_for_commit)) = + (label_for_commit.upgrade(), popover_for_commit.upgrade()) + else { + return; + }; // Format faithfully to the column kind: integers as "{}", // floats as f64::Display (drops trailing zeros, no fixed // precision so we don't lie about precision we don't have). @@ -1147,14 +1161,23 @@ fn show_json_popover(label: &super::cell_editor::CellEditor, col_index: usize, s popover.set_parent(label); POPOVER_SLOT.set(label, popover.clone()); - let popover_for_cancel = popover.clone(); - cancel_button.connect_clicked(move |_| popover_for_cancel.popdown()); + let popover_for_cancel = popover.downgrade(); + cancel_button.connect_clicked(move |_| { + if let Some(popover) = popover_for_cancel.upgrade() { + popover.popdown(); + } + }); - let label_for_commit = label.clone(); - let popover_for_commit = popover.clone(); + let label_for_commit = label.downgrade(); + let popover_for_commit = popover.downgrade(); let sender_for_commit = sender.clone(); let buffer_for_commit = buffer.clone(); save_button.connect_clicked(move |_| { + let (Some(label_for_commit), Some(popover_for_commit)) = + (label_for_commit.upgrade(), popover_for_commit.upgrade()) + else { + return; + }; let start = buffer_for_commit.start_iter(); let end = buffer_for_commit.end_iter(); let text = buffer_for_commit.text(&start, &end, true).to_string(); @@ -1182,14 +1205,14 @@ fn show_json_popover(label: &super::cell_editor::CellEditor, col_index: usize, s /// per-cell `POPOVER_SLOT`. Used by all three editor popovers /// (calendar, spin button, JSON sourceview) to share one lifecycle. fn install_popover_close_cleanup(label: &super::cell_editor::CellEditor, popover: >k::Popover) { - let label_for_close = label.clone(); - // Use the callback's first parameter rather than a captured clone - // so the closure doesn't hold a strong reference back to the - // popover. A self-capture would form an Rc cycle that delays the - // popover's finalisation past popdown. + // The cell holds the popover in its slot and as its child, so the + // popover holds the cell weakly and itself not at all. + let label_for_close = label.downgrade(); popover.connect_closed(move |p| { p.unparent(); - POPOVER_SLOT.take(&label_for_close); + if let Some(label) = label_for_close.upgrade() { + POPOVER_SLOT.take(&label); + } }); } @@ -1213,9 +1236,13 @@ fn install_edit_commit_handler( // the IME context and emits preedit-changed. { let text = label.entry(); - let label_for_preedit = label.clone(); + // The entry is the cell's own child, so it holds the cell weakly. + let label_for_preedit = label.downgrade(); let sender_for_preedit = sender.clone(); text.connect_preedit_changed(move |_t, preedit| { + let Some(label_for_preedit) = label_for_preedit.upgrade() else { + return; + }; let active = !preedit.is_empty(); PREEDIT_SLOT.set(&label_for_preedit, active); // If preedit just cleared and editing has already ended @@ -1278,6 +1305,24 @@ struct CellContext { column_name: String, } +/// The right-clicked cell as the shared menu keeps it. The cell holds +/// the menu through its gesture, so the menu holds the cell weakly. +struct ClickedCell { + widget: glib::WeakRef, + col_index: usize, + column_name: String, +} + +impl ClickedCell { + fn get(&self) -> Option { + Some(CellContext { + widget: self.widget.upgrade()?, + col_index: self.col_index, + column_name: self.column_name.clone(), + }) + } +} + /// What one cell wants from the shared menu: which popover it shows, /// and which of the per-cell actions apply to it. #[derive(Clone)] @@ -1313,7 +1358,7 @@ impl CellActions { #[derive(Clone)] pub(super) struct GridMenus { - context: Rc>>, + context: Rc>>, editable_popover: gtk::PopoverMenu, readonly_popover: gtk::PopoverMenu, actions: CellActions, @@ -1435,7 +1480,7 @@ fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { tab_ctx, editable, } = init; - let context: Rc>> = Rc::new(RefCell::new(None)); + let context: Rc>> = Rc::new(RefCell::new(None)); // Every action closure holds the view weakly. The action group // belongs to the ColumnView, so a strong clone in a closure is a // cycle: the grid, its selection model, its store and every row it @@ -1451,7 +1496,8 @@ fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { let ctx = context.clone(); gio::ActionEntry::builder($name) .activate(move |_, _, _| { - if let Some($slot) = ctx.borrow().as_ref() { + let clicked = ctx.borrow().as_ref().and_then(ClickedCell::get); + if let Some($slot) = clicked.as_ref() { $body; } }) @@ -1883,8 +1929,8 @@ fn attach_cell_gesture( let actions = menus.actions.clone(); let target = target.clone(); move |widget: >k::Widget, column_view: >k::ColumnView| { - *context.borrow_mut() = Some(CellContext { - widget: widget.clone(), + *context.borrow_mut() = Some(ClickedCell { + widget: widget.downgrade(), col_index: target.col_index, column_name: target.column_name.clone(), }); @@ -1895,23 +1941,26 @@ fn attach_cell_gesture( } }; - let widget_for_gesture = widget.clone(); + // The handlers below read the cell from their own arguments: the + // cell owns its controllers, so capturing it would be a cycle. let view_for_gesture = column_view.clone(); let popover_for_gesture = popover.clone(); let prepare_for_gesture = prepare.clone(); let gesture = gtk::GestureClick::new(); gesture.set_button(3); gesture.connect_pressed(move |g, _, x, y| { - let Some(cv) = view_for_gesture.upgrade() else { return }; + let (Some(cv), Some(cell)) = (view_for_gesture.upgrade(), g.widget()) else { + return; + }; g.set_state(gtk::EventSequenceState::Claimed); - prepare_for_gesture(&widget_for_gesture, &cv); + prepare_for_gesture(&cell, &cv); // Translate the click point into the ColumnView's coordinate // space — the popover is parented to the ColumnView so // pointing_to is interpreted there, not in cell-local coords. // `compute_point` is the GTK 4.12+ replacement for the // deprecated `translate_coordinates`. let local = gtk::graphene::Point::new(x as f32, y as f32); - let (cv_x, cv_y) = widget_for_gesture + let (cv_x, cv_y) = cell .compute_point(&cv, &local) .map(|p| (p.x() as i32, p.y() as i32)) .unwrap_or((x as i32, y as i32)); @@ -1920,7 +1969,6 @@ fn attach_cell_gesture( }); widget.add_controller(gesture); - let widget_for_key = widget.clone(); let view_for_key = column_view.clone(); let popover_for_key = popover; let menu_shortcut = gtk::Shortcut::builder() @@ -1928,15 +1976,15 @@ fn attach_cell_gesture( gtk::gdk::Key::Menu, gtk::gdk::ModifierType::empty(), )) - .action(>k::CallbackAction::new(move |_, _| { + .action(>k::CallbackAction::new(move |cell, _| { let Some(cv) = view_for_key.upgrade() else { return glib::Propagation::Proceed; }; - prepare(&widget_for_key, &cv); + prepare(cell, &cv); // Anchor on the cell's full bounds so the popover lands // visually under the cell rather than at an arbitrary // mouse-position-of-last-click. - if let Some(bounds) = widget_for_key.compute_bounds(&cv) { + if let Some(bounds) = cell.compute_bounds(&cv) { let rect = gtk::gdk::Rectangle::new( bounds.x() as i32, bounds.y() as i32, @@ -2219,6 +2267,117 @@ mod tests { crate::test_support::drain_main_context(); } + fn width_binding(tasks: &tablepro_session::runtime::Tasks, root: &tempfile::TempDir) -> ColumnWidthBinding { + ColumnWidthBinding { + store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), tasks), + connection_id: uuid::Uuid::new_v4(), + schema: None, + table: "t".to_owned(), + } + } + + #[gtk4::test] + fn column_freed_with_column_view() { + let runtime = crate::test_support::test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let mut name = col("text", false); + name.name = "name".into(); + let columns = vec![name]; + let result = QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Text("a".into())]], + ); + let (sender, _receiver) = relm4::channel::(); + let context = TabGridContext { + tracker: None, + pk_col_indices: Vec::new(), + column_widths: Some(width_binding(&runtime.tasks(), &root)), + }; + let (view, selection) = build_column_view( + &result, + &columns, + sender.clone(), + true, + Some((0, true)), + Some(sender), + context, + ); + let column = view + .columns() + .item(0) + .and_downcast::() + .expect("a column") + .downgrade(); + let view_ref = view.downgrade(); + + drop((view, selection)); + crate::test_support::drain_main_context(); + + assert!(column.upgrade().is_none(), "the column outlived its column view"); + assert!(view_ref.upgrade().is_none(), "the column view outlived its owner"); + } + + fn menu_gesture(cell: >k::Widget) -> Option { + let controllers = cell.observe_controllers(); + (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .find(|gesture| gesture.button() == 3) + } + + #[gtk4::test] + fn cells_freed_with_column_view() { + let mut id = col("integer", true); + id.name = "id".into(); + let mut name = col("text", false); + name.name = "name".into(); + let mut flag = col("boolean", false); + flag.name = "flag".into(); + let columns = vec![id, name, flag]; + let result = QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(7), Value::Text("a".into()), Value::Bool(true)]], + ); + let (sender, _receiver) = relm4::channel::(); + let context = TabGridContext { + tracker: Some(TabTrackerHandle::default()), + pk_col_indices: vec![0], + column_widths: None, + }; + let (view, selection) = build_column_view(&result, &columns, sender, true, None, None, context); + let window = gtk::Window::new(); + window.set_child(Some(&view)); + window.present(); + let cells = || -> Vec { + crate::test_support::descendants(&view) + .into_iter() + .filter(|widget| { + widget.is::() + || widget.is::() + || widget + .downcast_ref::() + .is_some_and(|label| label.text() == "7") + }) + .collect() + }; + crate::test_support::wait_until(std::time::Duration::from_secs(5), || cells().len() == 3) + .expect("the read-only, text and bool cells are drawn"); + // A right-click is what fills the menu's target, so it has to be + // in the picture too. + for cell in cells() { + let gesture = menu_gesture(&cell).expect("a cell answers a right-click"); + gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + } + let freed: Vec> = cells().iter().map(ObjectExt::downgrade).collect(); + + window.destroy(); + drop(window); + drop((view, selection)); + crate::test_support::drain_main_context(); + + let alive = freed.iter().filter(|cell| cell.upgrade().is_some()).count(); + assert_eq!(alive, 0, "cells outlived their column view"); + } + #[test] fn a_tracked_draft_carries_its_insert_tint_and_error_flash() { let tracker = TabTrackerHandle::default(); From 80f5931e0b9154bc0a091a85baaae9dd8ae488b3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:14:26 +0700 Subject: [PATCH 065/178] fix(linux-filter): free the filter strip with its browse tab --- linux/crates/app/src/ui/browse_tab.rs | 65 ++++++++- linux/crates/app/src/ui/filter_strip.rs | 180 ++++++++++++++++++------ 2 files changed, 193 insertions(+), 52 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab.rs b/linux/crates/app/src/ui/browse_tab.rs index 5508117a2d..49e9f50a3f 100644 --- a/linux/crates/app/src/ui/browse_tab.rs +++ b/linux/crates/app/src/ui/browse_tab.rs @@ -1477,15 +1477,23 @@ impl SimpleComponent for BrowseTab { root.add_top_bar(&filter_strip.widget); root.set_content(Some(&inner_stack)); + // The banners and the strip hold this through their notify + // handlers, and the root holds them, so it holds all four weakly. let sync_top_bar_slot: std::rc::Rc = { - let root_for_sync = root.clone(); - let read_only_for_sync = read_only_banner.clone(); - let no_pk_for_sync = no_pk_banner.clone(); - let filter_for_sync = filter_strip.widget.clone(); + let root_for_sync = root.downgrade(); + let read_only_for_sync = read_only_banner.downgrade(); + let no_pk_for_sync = no_pk_banner.downgrade(); + let filter_for_sync = filter_strip.widget.downgrade(); std::rc::Rc::new(move || { - let any_revealed = - read_only_for_sync.is_revealed() || no_pk_for_sync.is_revealed() || filter_for_sync.reveals_child(); - root_for_sync.set_reveal_top_bars(any_revealed); + let (Some(root), Some(read_only), Some(no_pk), Some(filter)) = ( + root_for_sync.upgrade(), + read_only_for_sync.upgrade(), + no_pk_for_sync.upgrade(), + filter_for_sync.upgrade(), + ) else { + return; + }; + root.set_reveal_top_bars(read_only.is_revealed() || no_pk.is_revealed() || filter.reveals_child()); }) }; sync_top_bar_slot(); @@ -2904,4 +2912,47 @@ mod tests { assert_eq!(store.n_items(), 1); assert_eq!(row_at(&store, 0).draft_id(), None); } + + #[gtk4::test] + fn browse_tab_and_filter_strip_freed_after_drop() { + use relm4::{Component, ComponentController}; + + libadwaita::init().expect("libadwaita initialises under the test backend"); + let runtime = crate::test_support::test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let controller = super::BrowseTab::builder() + .launch(super::BrowseTabInit { + tracker: crate::services::tab_tracker_handle::TabTrackerHandle::default(), + schema: None, + table: "t".to_owned(), + connection_id: Some(uuid::Uuid::new_v4()), + read_only: false, + page_size: 100, + initial_offset: 0, + initial_sort: None, + column_widths: crate::persistence::ColumnWidthStore::load( + root.path().join("column-widths.json"), + &runtime.tasks(), + ), + filter_settings: crate::persistence::FilterSettingsStore::load( + root.path().join("filters.json"), + &runtime.tasks(), + ), + }) + .detach(); + crate::test_support::drain_main_context(); + let strip = controller + .model() + .filter_strip + .as_ref() + .map(|strip| strip.widget.downgrade()) + .expect("the filter strip"); + let tab = controller.widget().downgrade(); + + drop(controller); + crate::test_support::drain_main_context(); + + assert!(strip.upgrade().is_none(), "the filter strip outlived its tab"); + assert!(tab.upgrade().is_none(), "the browse tab outlived its controller"); + } } diff --git a/linux/crates/app/src/ui/filter_strip.rs b/linux/crates/app/src/ui/filter_strip.rs index fd5d99e70e..799cfb7820 100644 --- a/linux/crates/app/src/ui/filter_strip.rs +++ b/linux/crates/app/src/ui/filter_strip.rs @@ -32,8 +32,9 @@ use std::cell::RefCell; use std::rc::Rc; -use relm4::adw::prelude::*; -use relm4::{adw, gtk}; +use gtk4::{self as gtk, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; use tablepro_core::{ColumnInfo, Combinator, FilterOp, FilterRule, FilterSet, FilterValue}; @@ -333,10 +334,13 @@ pub fn build(columns: Vec, initial: FilterSet, on_apply: Rc, initial: FilterSet, on_apply: Rc, initial: FilterSet, on_apply: Rc gtk::InputPurpose { false => gtk::InputPurpose::FreeForm, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{drain_main_context, first_descendant_of_type}; + + fn rule(column: &str) -> FilterRule { + FilterRule { + column: column.to_owned(), + op: FilterOp::Eq, + value: Some(FilterValue::Single("1".to_owned())), + } + } + + fn strip_with(rules: Vec) -> FilterStrip { + build( + vec![placeholder_column("a"), placeholder_column("b")], + FilterSet { + rules, + ..FilterSet::default() + }, + Rc::new(|_| {}), + ) + } + + fn row_count(list: >k::ListBox) -> usize { + std::iter::successors(list.first_child(), |row| row.next_sibling()).count() + } + + #[gtk4::test] + fn strip_freed_after_drop() { + let strip = strip_with(vec![rule("a"), rule("b")]); + let rules = first_descendant_of_type::(&strip.widget) + .expect("the rule list") + .downgrade(); + let revealer = strip.widget.downgrade(); + + drop(strip); + drain_main_context(); + + assert!(rules.upgrade().is_none(), "the rule list outlived the strip"); + assert!(revealer.upgrade().is_none(), "the strip outlived its owner"); + } + + #[gtk4::test] + fn an_applied_filter_rebuilds_the_rule_list() { + let strip = strip_with(vec![rule("a"), rule("b")]); + let rules = first_descendant_of_type::(&strip.widget).expect("the rule list"); + assert_eq!(row_count(&rules), 2); + + strip.update_filter(FilterSet { + rules: vec![rule("b")], + ..FilterSet::default() + }); + + assert_eq!(row_count(&rules), 1); + } +} From a8c4a2ececb73d844c36f14aabe4e021128aa96f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:14:27 +0700 Subject: [PATCH 066/178] fix(linux-tabs): free the workspace tab view and toast overlay --- linux/crates/app/src/ui/app/workspace_tabs.rs | 447 +++++++++++------- 1 file changed, 268 insertions(+), 179 deletions(-) diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index 1f08564629..bcc58c34b4 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -36,165 +36,38 @@ impl App { } fn build_workspace_root(&mut self, sender: ComponentSender) { - let tab_view = adw::TabView::new(); - let tab_bar = adw::TabBar::builder() - .view(&tab_view) - .autohide(false) - .expand_tabs(true) - .build(); - - let overview_button = adw::TabButton::builder() - .view(&tab_view) - .action_name("overview.open") - .tooltip_text(crate::i18n::gettext("View open tabs")) - .valign(gtk::Align::Center) - .build(); - // Hide the overview button until there are at least 2 tabs — - // its grid is meaningless when there's only one page open. - // Bound via property-binding so it tracks `n-pages` live. - overview_button.set_visible(tab_view.n_pages() > 1); - let overview_button_for_pages = overview_button.clone(); - tab_view.connect_n_pages_notify(move |view| { - overview_button_for_pages.set_visible(view.n_pages() > 1); - }); - tab_bar.set_start_action_widget(Some(&overview_button)); - - // "+" button in the tab bar opens a new editor (query) tab — - // this is the only way to create an editor tab from the UI. - // Browse tabs come from sidebar clicks. - let new_query_button = gtk::Button::builder() - .icon_name(crate::ui::icons::TAB_NEW) - .tooltip_text(crate::i18n::gettext("New query (Ctrl+E)")) - .valign(gtk::Align::Center) - .build(); - new_query_button.add_css_class("flat"); - let new_tab_sender = sender.clone(); - new_query_button.connect_clicked(move |_| new_tab_sender.input(AppMsg::NewEditorTab)); - tab_bar.set_end_action_widget(Some(&new_query_button)); - - // 2-step close: TabView signals close → App message → close_finish. - let close_sender = sender.clone(); - tab_view.connect_close_page(move |_view, page| { - if let Some(id) = read_workspace_tab_id(page) { - close_sender.input(AppMsg::WorkspaceTabClosed(id)); - } - glib::Propagation::Stop - }); - - // Right-click tab → context menu. AdwTabView reads a menu model - // and fires `connect_setup_menu` with the target page just - // before the popover opens; we stash that page in a shared - // Cell so the action callbacks know which tab the user - // right-clicked. Setting `menu-model` on AdwTabView is the - // documented way to extend the per-tab popover. - let menu_target: Rc>> = Rc::new(RefCell::new(None)); - let menu_target_setup = menu_target.clone(); - tab_view.connect_setup_menu(move |_view, page| { - *menu_target_setup.borrow_mut() = page.cloned(); - }); - let action_group = gio::SimpleActionGroup::new(); - let close_others_action = gio::SimpleAction::new("close-others", None); - let menu_target_others = menu_target.clone(); - let sender_close_others = sender.clone(); - close_others_action.connect_activate(move |_, _| { - let target = menu_target_others.borrow().clone(); - if let Some(page) = target - && let Some(id) = read_workspace_tab_id(&page) - { - sender_close_others.input(AppMsg::CloseOtherWorkspaceTabs(id)); - } - }); - action_group.add_action(&close_others_action); - let close_right_action = gio::SimpleAction::new("close-right", None); - let menu_target_right = menu_target; - let sender_close_right = sender.clone(); - close_right_action.connect_activate(move |_, _| { - let target = menu_target_right.borrow().clone(); - if let Some(page) = target - && let Some(id) = read_workspace_tab_id(&page) - { - sender_close_right.input(AppMsg::CloseWorkspaceTabsToRight(id)); - } - }); - action_group.add_action(&close_right_action); - tab_view.insert_action_group("tab", Some(&action_group)); - - let bulk_menu = gio::Menu::new(); - bulk_menu.append( - Some(&crate::i18n::gettext("Close Other Tabs")), - Some("tab.close-others"), - ); - bulk_menu.append( - Some(&crate::i18n::gettext("Close Tabs to the Right")), - Some("tab.close-right"), - ); - tab_view.set_menu_model(Some(&bulk_menu)); - - // Both selection-change AND any pages-list change (insert / - // remove / drag-reorder) trigger persist + title-refresh. - // Without connect_pages_notify, drag-reorder doesn't persist - // until the next other event. - let pages_sender = sender.clone(); - tab_view.connect_selected_page_notify(move |_| { - pages_sender.input(AppMsg::WorkspaceTabsChanged); - }); - let reorder_sender = sender.clone(); - tab_view.connect_pages_notify(move |_| { - reorder_sender.input(AppMsg::WorkspaceTabsChanged); - }); - - let inner = gtk::Box::builder().orientation(gtk::Orientation::Vertical).build(); - inner.append(&tab_bar); - inner.append(&tab_view); - - let tab_overview = adw::TabOverview::builder() - .view(&tab_view) - .enable_new_tab(true) // overview "+" → editor tab - .enable_search(true) - .child(&inner) - .build(); - // Overview "+" button must return a real TabPage synchronously — - // we build an SqlEditor inline, register the slot, and return - // the page. Browse tabs aren't creatable from the overview - // (they need a sidebar table target). - // Overview "+" must return a real TabPage synchronously. We - // construct an editor slot inline using the same label scheme - // as `append_editor_tab` ("Query 1", "Query 2", …) so the two - // entry points are visually indistinguishable. - let workspace_tabs_for_create = self.workspace_tabs.clone(); - let tab_view_for_create = tab_view.clone(); - let schema_buffer_for_create = self.schema_buffer.clone(); - let settings_for_create = self.settings.clone(); - let history_for_create = self.history.store(); - let tasks_for_create = self.tasks.clone(); - let session_for_create = self.context.active.clone(); - let hold_for_create = self.context.hold.clone(); - let close_timeout_for_create = self.context.close_timeout(); - let sender_for_create = sender.clone(); - let toasts_for_create = self.toast_overlay.clone(); - tab_overview.connect_create_tab(move |_| { + let workspace_tabs = self.workspace_tabs.clone(); + let schema_buffer = self.schema_buffer.clone(); + let settings = self.settings.clone(); + let history = self.history.store(); + let tasks = self.tasks.clone(); + let session = self.context.active.clone(); + let hold = self.context.hold.clone(); + let close_timeout = self.context.close_timeout(); + let input = sender.input_sender().clone(); + let toasts = self.toast_overlay.downgrade(); + // Numbered like the tabs `append_editor_tab` makes, so the two + // ways of opening one look the same. + let create_tab = move |tab_view: &adw::TabView| { let tab_id = Uuid::new_v4(); let editor = SqlEditor::new(SqlEditorInit { - schema_buffer: schema_buffer_for_create.clone(), + schema_buffer: schema_buffer.clone(), initial_query: None, - settings: settings_for_create.clone(), - history: history_for_create.clone(), - tasks: tasks_for_create.clone(), - session: session_for_create.clone(), - hold: hold_for_create.clone(), - close_timeout: close_timeout_for_create, + settings: settings.clone(), + history: history.clone(), + tasks: tasks.clone(), + session: session.clone(), + hold: hold.clone(), + close_timeout, }); - connect_editor(&editor, tab_id, &sender_for_create, &toasts_for_create); - let page = tab_view_for_create.append(&editor); - let editor_count = workspace_tabs_for_create + connect_editor(&editor, tab_id, &input, &toasts); + let page = tab_view.append(&editor); + let editor_count = workspace_tabs .borrow() .values() .filter(|t| matches!(t, WorkspaceTab::Editor(_))) .count(); - let label = default_editor_tab_label(editor_count + 1); - page.set_title(&label); - // Empty query → no tooltip; tooltip will be set on first edit - // via on_editor_tab_query_changed. + page.set_title(&default_editor_tab_label(editor_count + 1)); write_workspace_tab_id(&page, tab_id); let slot = EditorTabSlot { editor, @@ -202,23 +75,13 @@ impl App { draft: tablepro_storage::DraftId::new(), query: String::new(), }; - { - let mut tabs = workspace_tabs_for_create.borrow_mut(); - tabs.insert(tab_id, WorkspaceTab::Editor(slot)); - } - sender_for_create.input(AppMsg::WorkspaceTabsChanged); + workspace_tabs.borrow_mut().insert(tab_id, WorkspaceTab::Editor(slot)); + input.emit(AppMsg::WorkspaceTabsChanged); page - }); - - // Ctrl+T is bound globally (alongside Ctrl+E) in - // `install_window_shortcuts` so it fires even when this - // tab-area widget tree isn't focused (e.g. the empty-state - // status page). Keeping a local controller here would just - // duplicate the binding. - let _ = sender; - - self.workspace_root = Some(tab_overview); - self.workspace_tab_view = Some(tab_view); + }; + let chrome = build_workspace_chrome(sender.input_sender(), create_tab); + self.workspace_root = Some(chrome.overview); + self.workspace_tab_view = Some(chrome.view); } /// Read the saved tabs for the just-connected database, plus the @@ -584,7 +447,7 @@ impl App { hold: self.context.hold.clone(), close_timeout: self.context.close_timeout(), }); - connect_editor(&editor, tab_id, &sender, &self.toast_overlay); + connect_editor(&editor, tab_id, sender.input_sender(), &self.toast_overlay.downgrade()); let page = tab_view.append(&editor); let label = match query.trim().is_empty() { true => default_editor_tab_label(self.editor_tab_count() + 1), @@ -1288,6 +1151,140 @@ impl App { } } +/// What a connected window's tabs live in: the overview, and the view +/// inside it that holds the pages. +struct WorkspaceChrome { + overview: adw::TabOverview, + view: adw::TabView, +} + +/// Build the tab bar, view and overview. `create_tab` makes the page the +/// overview's own new-tab button asks for, which it needs back at once. +fn build_workspace_chrome( + input: &relm4::Sender, + create_tab: impl Fn(&adw::TabView) -> adw::TabPage + 'static, +) -> WorkspaceChrome { + let tab_view = adw::TabView::new(); + let tab_bar = adw::TabBar::builder() + .view(&tab_view) + .autohide(false) + .expand_tabs(true) + .build(); + + let overview_button = adw::TabButton::builder() + .view(&tab_view) + .action_name("overview.open") + .tooltip_text(crate::i18n::gettext("View open tabs")) + .valign(gtk::Align::Center) + .build(); + // With one page the overview has nothing to choose between. A + // binding, unlike a notify handler, holds the button weakly, and the + // button holds the view. + tab_view + .bind_property("n-pages", &overview_button, "visible") + .transform_to(|_, pages: i32| Some(pages > 1)) + .sync_create() + .build(); + tab_bar.set_start_action_widget(Some(&overview_button)); + + // Browse tabs need a table to show, so they open from the sidebar, + // and this button only makes editor tabs. + let new_query_button = gtk::Button::builder() + .icon_name(crate::ui::icons::TAB_NEW) + .tooltip_text(crate::i18n::gettext("New query (Ctrl+E)")) + .valign(gtk::Align::Center) + .build(); + new_query_button.add_css_class("flat"); + let new_tab_input = input.clone(); + new_query_button.connect_clicked(move |_| new_tab_input.emit(AppMsg::NewEditorTab)); + tab_bar.set_end_action_widget(Some(&new_query_button)); + + // A tab with unsaved work asks first, so the App finishes the close. + let close_input = input.clone(); + tab_view.connect_close_page(move |_view, page| { + if let Some(id) = read_workspace_tab_id(page) { + close_input.emit(AppMsg::WorkspaceTabClosed(id)); + } + glib::Propagation::Stop + }); + + // The view names the right-clicked page only while its menu is + // open, so the actions read it from here. + let menu_target: Rc>> = Rc::new(RefCell::new(None)); + let menu_target_setup = menu_target.clone(); + tab_view.connect_setup_menu(move |_view, page| { + *menu_target_setup.borrow_mut() = page.cloned(); + }); + let action_group = gio::SimpleActionGroup::new(); + let close_others_action = gio::SimpleAction::new("close-others", None); + let menu_target_others = menu_target.clone(); + let close_others_input = input.clone(); + close_others_action.connect_activate(move |_, _| { + let target = menu_target_others.borrow().clone(); + if let Some(page) = target + && let Some(id) = read_workspace_tab_id(&page) + { + close_others_input.emit(AppMsg::CloseOtherWorkspaceTabs(id)); + } + }); + action_group.add_action(&close_others_action); + let close_right_action = gio::SimpleAction::new("close-right", None); + let menu_target_right = menu_target; + let close_right_input = input.clone(); + close_right_action.connect_activate(move |_, _| { + let target = menu_target_right.borrow().clone(); + if let Some(page) = target + && let Some(id) = read_workspace_tab_id(&page) + { + close_right_input.emit(AppMsg::CloseWorkspaceTabsToRight(id)); + } + }); + action_group.add_action(&close_right_action); + tab_view.insert_action_group("tab", Some(&action_group)); + + let bulk_menu = gio::Menu::new(); + bulk_menu.append( + Some(&crate::i18n::gettext("Close Other Tabs")), + Some("tab.close-others"), + ); + bulk_menu.append( + Some(&crate::i18n::gettext("Close Tabs to the Right")), + Some("tab.close-right"), + ); + tab_view.set_menu_model(Some(&bulk_menu)); + + // A drag reorder changes only `pages`, and it has to persist too. + let selected_input = input.clone(); + tab_view.connect_selected_page_notify(move |_| selected_input.emit(AppMsg::WorkspaceTabsChanged)); + let pages_input = input.clone(); + tab_view.connect_pages_notify(move |_| pages_input.emit(AppMsg::WorkspaceTabsChanged)); + + let inner = gtk::Box::builder().orientation(gtk::Orientation::Vertical).build(); + inner.append(&tab_bar); + inner.append(&tab_view); + + let overview = adw::TabOverview::builder() + .view(&tab_view) + .enable_new_tab(true) + .enable_search(true) + .child(&inner) + .build(); + // `connect_create_tab` has to return a page, and an overview with no + // view has none to make. + overview.connect_closure( + "create-tab", + false, + glib::closure_local!(move |overview: &adw::TabOverview| -> Option { + overview.view().map(|view| create_tab(&view)) + }), + ); + + WorkspaceChrome { + overview, + view: tab_view, + } +} + pub(super) fn qualified_browse_tab_label(schemas_count: usize, schema: Option<&str>, table: &str) -> String { if schemas_count >= 2 && let Some(s) = schema @@ -1417,27 +1414,37 @@ fn editor_tab_tooltip(query: &str, label: &str) -> Option { /// Forward an editor tab's signals into the window. The toast overlay /// is the window's, so a replaced query's Undo button lives there. -fn connect_editor(editor: &SqlEditor, tab_id: Uuid, sender: &ComponentSender, toasts: &adw::ToastOverlay) { - let input = sender.input_sender().clone(); +fn connect_editor( + editor: &SqlEditor, + tab_id: Uuid, + input: &relm4::Sender, + toasts: &glib::WeakRef, +) { + let run_state = input.clone(); editor.connect_run_state_changed(move |_, running| { - input.emit(AppMsg::EditorTabRunStateChanged(tab_id, running)); + run_state.emit(AppMsg::EditorTabRunStateChanged(tab_id, running)); }); - let input = sender.input_sender().clone(); - editor.connect_query_changed(move |_, text| input.emit(AppMsg::EditorTabQueryChanged(tab_id, text))); - let input = sender.input_sender().clone(); - editor.connect_copy_to_clipboard(move |_, text| input.emit(AppMsg::CopyToClipboard(text))); - let input = sender.input_sender().clone(); - editor.connect_toast(move |_, text| input.emit(AppMsg::ShowToast(text))); - let input = sender.input_sender().clone(); + let query = input.clone(); + editor.connect_query_changed(move |_, text| query.emit(AppMsg::EditorTabQueryChanged(tab_id, text))); + let clipboard = input.clone(); + editor.connect_copy_to_clipboard(move |_, text| clipboard.emit(AppMsg::CopyToClipboard(text))); + let toast = input.clone(); + editor.connect_toast(move |_, text| toast.emit(AppMsg::ShowToast(text))); + let export = input.clone(); editor.connect_export_results(move |_, result, name| { - input.emit(AppMsg::ExportResults { + export.emit(AppMsg::ExportResults { result, name, target: None, }); }); + // The overlay is an ancestor of the editor, so a strong capture + // would keep both alive once the window lets go of them. let toasts = toasts.clone(); editor.connect_undoable_toast(move |editor, title| { + let Some(toasts) = toasts.upgrade() else { + return; + }; let toast = adw::Toast::new(&title); toast.set_button_label(Some(&crate::i18n::gettext("Undo"))); let editor = editor.downgrade(); @@ -1449,3 +1456,85 @@ fn connect_editor(editor: &SqlEditor, tab_id: Uuid, sender: &ComponentSender adw::TabPage { + view.append(>k::Label::new(None)) + } + + #[gtk4::test] + fn tab_view_freed_after_teardown() { + adw::init().expect("libadwaita initialises under the test backend"); + let (input, _receiver) = relm4::channel::(); + let chrome = build_workspace_chrome(&input, blank_page); + let stack = gtk::Stack::new(); + stack.add_named(&chrome.overview, Some("tabs")); + blank_page(&chrome.view); + let created = chrome.overview.emit_by_name::>("create-tab", &[]); + assert!(created.is_some(), "the overview's new-tab button made no page"); + drop(created); + assert_eq!(chrome.view.n_pages(), 2); + let view = chrome.view.downgrade(); + let overview = chrome.overview.downgrade(); + + stack.remove(&chrome.overview); + drop(chrome); + drain_main_context(); + + assert!(view.upgrade().is_none(), "the tab view outlived its teardown"); + assert!(overview.upgrade().is_none(), "the overview outlived its teardown"); + } + + #[gtk4::test] + fn overview_button_shows_from_the_second_tab() { + adw::init().expect("libadwaita initialises under the test backend"); + let (input, _receiver) = relm4::channel::(); + let chrome = build_workspace_chrome(&input, blank_page); + let button = find_by_action_name(&chrome.overview, "overview.open").expect("the overview button"); + assert!(!button.is_visible(), "shown with no tabs"); + + let first = blank_page(&chrome.view); + assert!(!button.is_visible(), "shown with one tab"); + blank_page(&chrome.view); + assert!(button.is_visible(), "hidden with two tabs"); + + chrome.view.close_page(&first); + chrome.view.close_page_finish(&first, true); + assert!(!button.is_visible(), "still shown after closing back to one tab"); + } + + #[gtk4::test] + fn an_editor_tab_leaves_the_toast_overlay_free() { + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let settings = crate::test_support::MemorySettings::new(); + let editor = SqlEditor::new(SqlEditorInit { + schema_buffer: crate::ui::editor::build_schema_buffer(), + initial_query: None, + settings: settings.get().clone(), + history: None, + tasks: runtime.tasks(), + session: crate::services::active_session::ActiveSession::new(), + hold: Rc::new(tablepro_session::runtime::CountingHold::new()), + close_timeout: std::time::Duration::from_secs(5), + }); + let toasts = adw::ToastOverlay::new(); + toasts.set_child(Some(&editor)); + let (input, _receiver) = relm4::channel::(); + connect_editor(&editor, Uuid::new_v4(), &input, &toasts.downgrade()); + let overlay = toasts.downgrade(); + + drop(editor); + drop(toasts); + drain_main_context(); + + assert!( + overlay.upgrade().is_none(), + "an editor tab kept the window's toast overlay alive" + ); + } +} From 348ecbb2fc4f16f507a4397b75fac735a805561f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:14:27 +0700 Subject: [PATCH 067/178] docs(linux): note the memory freed with closed tabs and grids --- linux/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 7f68f55227..c2583577a8 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Excel export rounded integers past 2^53 and wrote decimals as text when their column padded the scale. - Exported arrays wrote each row of a multi-dimensional array as a quoted string. - Markdown export broke its table when a column name held a pipe. +- Closed tabs, earlier result pages and structure edits kept using memory until TablePro quit. ### Security From 72c229b71aca3dc4484fe6cc2bdc91ca41724e52 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:29:17 +0700 Subject: [PATCH 068/178] fix(linux): resolve a commit MySQL keeps no record of as Unresolvable --- linux/crates/core/src/connection.rs | 4 ++- linux/crates/drivers/mysql/src/connection.rs | 9 +++-- linux/crates/drivers/sqlite/src/connection.rs | 1 + .../conformance/cases/commit_resolution.rs | 34 +++++++++++++++++++ .../test-support/src/conformance/cases/mod.rs | 1 + .../src/conformance/connection_suite_macro.rs | 6 ++++ linux/docs/adding-drivers.md | 4 +-- linux/docs/testing.md | 2 +- 8 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 linux/crates/test-support/src/conformance/cases/commit_resolution.rs diff --git a/linux/crates/core/src/connection.rs b/linux/crates/core/src/connection.rs index 84bf600e66..b04d34e2c2 100644 --- a/linux/crates/core/src/connection.rs +++ b/linux/crates/core/src/connection.rs @@ -108,7 +108,9 @@ pub trait Connection: Send + Sync { async fn write_batch(&self, batch: &WriteBatch, options: CallOptions) -> Result; /// Ask the server what became of a write whose commit was sent and - /// never answered. + /// never answered. A commit the engine keeps no record of is + /// `Unresolvable`, and an engine whose `commit_resolution` is + /// `Unavailable` keeps a record of none: neither is an error. async fn resolve_commit(&self, token: &CommitToken, options: CallOptions) -> Result; /// Open an editor tab's own connection. It outlives this one: the diff --git a/linux/crates/drivers/mysql/src/connection.rs b/linux/crates/drivers/mysql/src/connection.rs index 5f08ea6a4e..77c974647d 100644 --- a/linux/crates/drivers/mysql/src/connection.rs +++ b/linux/crates/drivers/mysql/src/connection.rs @@ -458,16 +458,15 @@ impl Connection for MysqlConnection { self.settle(outcome, &stop).await } - /// No COMMIT outcome can be asked about after the fact: MySQL keeps - /// no record of a transaction once its session is gone. + /// MySQL keeps no record of a transaction once its session is gone, + /// so a lost COMMIT has no answer. async fn resolve_commit( &self, _token: &CommitToken, _options: CallOptions, ) -> Result { - Err(DriverError::Unsupported { - feature: "commit resolution", - }) + self.ensure_open()?; + Ok(CommitResolution::Unresolvable) } async fn open_editor_session( diff --git a/linux/crates/drivers/sqlite/src/connection.rs b/linux/crates/drivers/sqlite/src/connection.rs index 9a3e25e0a1..5d9f05ab95 100644 --- a/linux/crates/drivers/sqlite/src/connection.rs +++ b/linux/crates/drivers/sqlite/src/connection.rs @@ -436,6 +436,7 @@ impl Connection for SqliteConnection { _token: &CommitToken, _options: CallOptions, ) -> Result { + self.ensure_open()?; Ok(CommitResolution::Unresolvable) } diff --git a/linux/crates/test-support/src/conformance/cases/commit_resolution.rs b/linux/crates/test-support/src/conformance/cases/commit_resolution.rs new file mode 100644 index 0000000000..ff3da36cc7 --- /dev/null +++ b/linux/crates/test-support/src/conformance/cases/commit_resolution.rs @@ -0,0 +1,34 @@ +use tablepro_core::{CommitResolution, CommitResolutionSupport, CommitToken, TableRef}; + +use crate::conformance::support::{CALL_DEADLINE, connect, expect, within, writable}; +use crate::conformance::{ConformanceEngine, ConformanceFailure}; + +const CASE: &str = "commit_with_no_record_is_unresolvable"; + +/// Case 16: asked about a commit it keeps no record of, a driver answers +/// `Unresolvable` rather than failing the question. An engine with no +/// commit resolution at all keeps a record of nothing. +pub async fn commit_with_no_record_is_unresolvable(engine: E) -> Result<(), ConformanceFailure> { + let connection = connect(CASE, &engine, writable()).await?; + // Each engine is handed another engine's token, which it can never + // have a record of. + let token = match E::driver().capabilities().commit_resolution { + CommitResolutionSupport::ServerTransactionStatus => CommitToken::ClickHouseQuery { + query_ids: vec![uuid::Uuid::new_v4()], + first_unconfirmed: 0, + table: TableRef::unqualified("t"), + }, + CommitResolutionSupport::QueryLog | CommitResolutionSupport::Unavailable => CommitToken::PostgresXid { + xid: 1, + legacy_txid: false, + }, + }; + + let answer = connection.resolve_commit(&token, within(CALL_DEADLINE)).await; + + expect(CASE, answer == Ok(CommitResolution::Unresolvable), || { + format!("a commit with no record resolved as {answer:?}") + })?; + connection.close().await?; + Ok(()) +} diff --git a/linux/crates/test-support/src/conformance/cases/mod.rs b/linux/crates/test-support/src/conformance/cases/mod.rs index e44f149368..376b256d8f 100644 --- a/linux/crates/test-support/src/conformance/cases/mod.rs +++ b/linux/crates/test-support/src/conformance/cases/mod.rs @@ -3,6 +3,7 @@ pub mod cancellation; pub mod close; +pub mod commit_resolution; pub mod deadlines; pub mod liveness; pub mod metadata; diff --git a/linux/crates/test-support/src/conformance/connection_suite_macro.rs b/linux/crates/test-support/src/conformance/connection_suite_macro.rs index f0029cb1a6..86512cf1cc 100644 --- a/linux/crates/test-support/src/conformance/connection_suite_macro.rs +++ b/linux/crates/test-support/src/conformance/connection_suite_macro.rs @@ -205,6 +205,12 @@ macro_rules! connection_conformance_suite { close_is_idempotent, $crate::conformance::cases::close::close_is_idempotent::<$engine> } + $crate::__conformance_case! { + $docker, + $engine as ConformanceEngine, + commit_with_no_record_is_unresolvable, + $crate::conformance::cases::commit_resolution::commit_with_no_record_is_unresolvable::<$engine> + } $crate::__conformance_case! { $docker, $engine as ConformanceEngine, diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index efc7f3fa99..73f03f0914 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -264,7 +264,7 @@ Where the engine can answer later, the ledger carries a `CommitToken`: - `PostgresXid { xid, legacy_txid }`: `pg_current_xact_id()` (or `txid_current()` before PostgreSQL 13), read inside the transaction before COMMIT, and asked back with `pg_xact_status` (or `txid_status`). - `ClickHouseQuery { query_ids, first_unconfirmed }`: the query id of every statement, looked up in `system.query_log`. -`resolve_commit` answers with a `CommitResolution`: `Applied`, `NotApplied`, `PartiallyApplied`, `StillRunning` or `Unresolvable`. Where `commit_resolution` is `Unavailable`, SQL Server and SQLite answer `Unresolvable` and MySQL returns `Unsupported`. The app reports `CommitOutcomeUnknown` as unknown and asks the user to reload; it does not call `resolve_commit` yet. +`resolve_commit` answers with a `CommitResolution`: `Applied`, `NotApplied`, `PartiallyApplied`, `StillRunning` or `Unresolvable`. A commit the engine keeps no record of is `Unresolvable`, never an error, so where `commit_resolution` is `Unavailable` (MySQL, SQL Server, SQLite) every answer is `Unresolvable`. The app reports `CommitOutcomeUnknown` as unknown and asks the user to reload; it does not call `resolve_commit` yet. ## 6. `EditorSession` @@ -356,7 +356,7 @@ tablepro_test_support::tls_conformance_suite! { } ``` -The cases cover listing and identity, deadlines within the cancel grace, a cancelled editor run, a dropped `query_bound` caller or editor run stopping the server's statement, a lost transport, a busy pool's probe, read-only refusal, write atomicity, row guards, the row limit, unbounded schema changes, streaming in bounded batches, type fallback and every TLS mode. [testing.md](testing.md#conformance) lists them with their flags. `suite_flags_match_driver_capabilities` runs without Docker and fails when a flag disagrees with the driver's capabilities, so a case cannot be skipped by declaring less. +The cases cover listing and identity, deadlines within the cancel grace, a cancelled editor run, a dropped `query_bound` caller or editor run stopping the server's statement, a lost transport, a busy pool's probe, read-only refusal, write atomicity, row guards, the row limit, unbounded schema changes, streaming in bounded batches, a commit with no record resolving as `Unresolvable`, type fallback and every TLS mode. [testing.md](testing.md#conformance) lists them with their flags. `suite_flags_match_driver_capabilities` runs without Docker and fails when a flag disagrees with the driver's capabilities, so a case cannot be skipped by declaring less. Behaviour the suites do not cover gets a test of its own against the real engine: the engine's stop, its error mapping, what a pooler does to it, each write outcome the ledger can produce. diff --git a/linux/docs/testing.md b/linux/docs/testing.md index e36eead0e1..425d97225e 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -111,7 +111,7 @@ tablepro_test_support::connection_conformance_suite! { } ``` -The cases every engine runs: a created table is listed with its key, a query bound times out within its deadline, a cancelled editor run reports it and leaves the session ready, a read-only connection refuses writes, a write batch commits every step and a failed one ends as the driver's atomicity says, a row guard mismatch applies nothing, `close` is idempotent, and `stream_read` streams every row in bounded batches. +The cases every engine runs: a created table is listed with its key, a query bound times out within its deadline, a cancelled editor run reports it and leaves the session ready, a read-only connection refuses writes, a write batch commits every step and a failed one ends as the driver's atomicity says, a row guard mismatch applies nothing, `close` is idempotent, a commit the engine keeps no record of resolves as `Unresolvable`, and `stream_read` streams every row in bounded batches. Each flag adds cases and needs its engine trait: From dbb86ed319f7a347502dbc01f1f8ba1cc3afc80b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:29:30 +0700 Subject: [PATCH 069/178] fix(linux-mysql): trust the system CA store under VerifyFull --- linux/ARCHITECTURE.md | 2 +- linux/CHANGELOG.md | 1 + linux/Cargo.lock | 1 + linux/crates/drivers/mysql/Cargo.toml | 1 + linux/crates/drivers/mysql/src/physical.rs | 36 +++-- .../drivers/mysql/tests/system_trust.rs | 146 ++++++++++++++++++ linux/docs/drivers/mysql.md | 2 +- 7 files changed, 171 insertions(+), 18 deletions(-) create mode 100644 linux/crates/drivers/mysql/tests/system_trust.rs diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 4bbfff2a81..8dc6632952 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -154,7 +154,7 @@ Each engine's TLS stack takes trust in its own form, so the driver builds it: |---|---|---|---| | PostgreSQL | The system store (rustls-platform-verifier), plus the CA file | Yes | The rustls error | | ClickHouse | The system store (rustls-platform-verifier), plus the CA file | Yes, as the login, with no password sent | The rustls error | -| MySQL, MariaDB | The Mozilla roots compiled into sqlx, plus the CA file. Not the system store | Yes | The rustls error | +| MySQL, MariaDB | The system store and the CA file as one PEM bundle (`tablepro_net::tls::mysql_ca_bundle_pem`), plus the Mozilla roots compiled into sqlx | Yes | The rustls error | | SQL Server | The system store (loaded by tiberius), plus the CA file | No | rustls's message text, because tiberius keeps nothing else | | SQLite | No TLS | | | diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index e24958e797..9b8031f29e 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. - PostgreSQL statement timeouts showed as a plain server error when the server's messages were not in English. +- MySQL and MariaDB connections that verify the certificate did not trust certificate authorities installed on the system. - SQL Server money and smallmoney in the SQL editor and query results showed as approximate floats. - Saving a SQL Server row with a char or varchar key locked the whole table until the save ended. - Stop could not end a SQLite statement waiting for another connection's lock. diff --git a/linux/Cargo.lock b/linux/Cargo.lock index ab9f7bfd0d..f2525bbdf6 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -4430,6 +4430,7 @@ dependencies = [ "tablepro-net", "tablepro-test-fixtures", "tablepro-test-support", + "tempfile", "thiserror", "tokio", "tokio-util", diff --git a/linux/crates/drivers/mysql/Cargo.toml b/linux/crates/drivers/mysql/Cargo.toml index 02dc0c7979..d7abf7c4a6 100644 --- a/linux/crates/drivers/mysql/Cargo.toml +++ b/linux/crates/drivers/mysql/Cargo.toml @@ -29,6 +29,7 @@ tablepro-test-support = { path = "../../test-support" } async-trait.workspace = true chrono.workspace = true secrecy.workspace = true +tempfile.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } tokio-util = { workspace = true, features = ["rt"] } tracing.workspace = true diff --git a/linux/crates/drivers/mysql/src/physical.rs b/linux/crates/drivers/mysql/src/physical.rs index 4558e1a208..b1ce6fabe0 100644 --- a/linux/crates/drivers/mysql/src/physical.rs +++ b/linux/crates/drivers/mysql/src/physical.rs @@ -76,23 +76,17 @@ impl MysqlEndpoint { if let Some(database) = target.database.as_deref().filter(|name| !name.is_empty()) { options = options.database(database); } + if target.tls.mode == TlsMode::VerifyFull { + // sqlx seeds its own Mozilla roots and takes any other trust + // only as PEM, so the system store and the CA file go in as + // one bundle: what PostgreSQL and ClickHouse trust too. + let roots = tablepro_net::tls::mysql_ca_bundle_pem(&target.tls) + .map_err(|error| tls_setup_failed(error, tablepro_core::TlsFailure::CaFileInvalid))?; + options = options.ssl_ca_from_pem(roots); + } if target.tls.mode != TlsMode::Disable { - if let Some(ca_file) = &target.tls.ca_file { - let pem = std::fs::read(ca_file).map_err(|error| ConfigError::CaFile { - path: ca_file.clone(), - problem: tablepro_core::CertificateFileProblem::Unreadable { - detail: error.to_string(), - }, - })?; - options = options.ssl_ca_from_pem(pem); - } - let identity = tablepro_net::tls::client_identity_pem(&target.tls).map_err(|error| match error { - tablepro_net::tls::TlsSetupError::Config(config) => DriverError::Config(config), - other => DriverError::Tls { - failure: tablepro_core::TlsFailure::ClientIdentityInvalid, - detail: other.to_string(), - }, - })?; + let identity = tablepro_net::tls::client_identity_pem(&target.tls) + .map_err(|error| tls_setup_failed(error, tablepro_core::TlsFailure::ClientIdentityInvalid))?; if let Some(identity) = identity { options = options .ssl_client_cert_from_pem(identity.certificate) @@ -237,6 +231,16 @@ pub(crate) fn no_limits() -> SessionLimits { } } +fn tls_setup_failed(error: tablepro_net::tls::TlsSetupError, failure: tablepro_core::TlsFailure) -> DriverError { + match error { + tablepro_net::tls::TlsSetupError::Config(config) => DriverError::Config(config), + other => DriverError::Tls { + failure, + detail: other.to_string(), + }, + } +} + fn ssl_mode(mode: TlsMode) -> MySqlSslMode { match mode { TlsMode::Disable => MySqlSslMode::Disabled, diff --git a/linux/crates/drivers/mysql/tests/system_trust.rs b/linux/crates/drivers/mysql/tests/system_trust.rs new file mode 100644 index 0000000000..14aa71dcff --- /dev/null +++ b/linux/crates/drivers/mysql/tests/system_trust.rs @@ -0,0 +1,146 @@ +//! `VerifyFull` trusts a CA the system store holds, as PostgreSQL and +//! ClickHouse do, and not only the roots compiled into sqlx. +//! +//! rustls-native-certs reads the system store from `SSL_CERT_FILE` when +//! it is set, so the test points it at a file it fills with the +//! fixture's CA. The variable is process-wide, which is why this is a +//! test binary of its own with one test, and why it is set before the +//! runtime starts any thread. + +#![expect( + clippy::expect_used, + reason = "the setup helpers fail a test the way the test functions themselves do" +)] + +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use drivers_mysql::MysqlDriver; +use secrecy::SecretString; +use tablepro_core::{ + ConnectEnvironment, ConnectOptions, ConnectTarget, Credentials, DatabaseDriver, DriverError, NetworkEndpoint, + NetworkTarget, TlsConfig, TlsFailure, TlsMode, Transport, TransportError, TransportKind, TransportRoute, + TransportStream, WorkspaceSettings, +}; +use tablepro_test_fixtures::{MysqlFixture, MysqlFlavour, PkiFile, TestPki}; +use tablepro_test_support::FaultTransport; +use tablepro_test_support::conformance::test_liveness; +use tokio_util::sync::CancellationToken; + +#[test] +#[ignore = "requires docker"] +fn verify_full_trusts_a_ca_in_the_system_store() { + let store = tempfile::tempdir().expect("a directory"); + let system_store = store.path().join("system-store.pem"); + // SAFETY: no other thread exists yet to read the environment. + unsafe { std::env::set_var("SSL_CERT_FILE", &system_store) }; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("a runtime"); + + runtime.block_on(async { + let fixture = MysqlFixture::start(MysqlFlavour::Mysql) + .await + .expect("a TLS-only server"); + + std::fs::write(&system_store, "").expect("an empty store"); + let untrusted = MysqlDriver + .connect(verified_without_ca_file(&fixture), CancellationToken::new()) + .await; + assert!( + matches!( + &untrusted, + Err(DriverError::Tls { + failure: TlsFailure::UnknownIssuer, + .. + }) + ), + "a CA in neither the store nor the CA file was trusted: {:?}", + untrusted.err() + ); + + trust(&fixture.pki().path(PkiFile::Ca), &system_store); + let trusted = MysqlDriver + .connect(verified_without_ca_file(&fixture), CancellationToken::new()) + .await + .expect("the system store's CA is trusted"); + trusted.close().await.expect("a close"); + }); +} + +fn trust(ca: &Path, system_store: &Path) { + std::fs::copy(ca, system_store).expect("the CA in the store"); +} + +/// Through a forward, so the certificate's name is checked against the +/// override rather than the address the container is reached at. +fn verified_without_ca_file(fixture: &MysqlFixture) -> ConnectOptions { + let liveness = test_liveness(); + let credentials = fixture.password_credentials(); + ConnectOptions { + target: ConnectTarget::Network(NetworkTarget { + transport: SocketForward::to(fixture), + credentials: Credentials::Password { + username: credentials.username.clone(), + password: SecretString::from(credentials.password.clone()), + }, + database: Some(fixture.database().to_owned()), + tls: TlsConfig { + mode: TlsMode::VerifyFull, + ca_file: None, + client_identity: None, + server_name: Some(TestPki::SERVER_NAMES[1].to_owned()), + }, + }), + workspace_settings: WorkspaceSettings::workspace(false), + liveness, + environment: ConnectEnvironment::new("test"), + } +} + +/// An SSH forward as the driver sees one: a Unix socket that leads to +/// the service. +#[derive(Debug)] +struct SocketForward { + relay: Arc, +} + +impl SocketForward { + fn to(fixture: &MysqlFixture) -> Arc { + let service = NetworkEndpoint::new(fixture.host(), fixture.port()).expect("an endpoint"); + let relay = FaultTransport::direct(service, &test_liveness()).expect("a relay"); + Arc::new(Self { relay: Arc::new(relay) }) + } +} + +#[async_trait] +impl Transport for SocketForward { + fn service_endpoint(&self) -> &NetworkEndpoint { + self.relay.service_endpoint() + } + + fn kind(&self) -> TransportKind { + TransportKind::SshForward { + destination: "bastion.tablepro.test".to_owned(), + } + } + + fn route(&self) -> TransportRoute { + self.relay.route() + } + + async fn open(&self) -> Result, TransportError> { + self.relay.open().await + } + + async fn reroute(&self, endpoint: NetworkEndpoint) -> Result, TransportError> { + self.relay.reroute(endpoint).await + } + + async fn explain_closed_stream(&self) -> Option { + None + } +} diff --git a/linux/docs/drivers/mysql.md b/linux/docs/drivers/mysql.md index 6620e98aa8..9edaec036c 100644 --- a/linux/docs/drivers/mysql.md +++ b/linux/docs/drivers/mysql.md @@ -50,7 +50,7 @@ sqlx's own session statements (`PIPES_AS_CONCAT`, `NO_ENGINE_SUBSTITUTION`, its - `Disable` connects without TLS. A server with `require_secure_transport` answers with error 3159, reported as `ServerRequiresTls`. - `Require` encrypts without checking the certificate. A server that does not offer TLS is reported as `ServerRefusedTls`. -- `VerifyFull` is sqlx's `VerifyIdentity`. It trusts the Mozilla roots compiled into sqlx plus the connection's CA file, and **not** the system's CA store ([Trust](../../ARCHITECTURE.md#trust)). A server certificate from a private CA always needs the CA file. +- `VerifyFull` is sqlx's `VerifyIdentity`. It trusts the system's CA store and the connection's CA file, which sqlx takes only as PEM, so the driver hands it both as one bundle; sqlx adds the Mozilla roots compiled into it ([Trust](../../ARCHITECTURE.md#trust)). `Require` checks no certificate, so it loads neither. - The name checked is the host sqlx dials, so a server name override works only through an SSH forward, where the dial goes to a socket path (`ServerNameOverride::SshForwardOnly`). - A client certificate and key are sent when set, for `REQUIRE X509` logins. - With TLS off, `caching_sha2_password` sends the password encrypted with the server's RSA key, never in clear text, also over an SSH forward's socket. From fc0f23dda72bc21b46fd89c62ba026d69754fc70 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:37:18 +0700 Subject: [PATCH 070/178] test(linux): let the dropped editor run case find each statement --- .../mssql/tests/integration/conformance_engine.rs | 2 +- .../mysql/tests/integration/conformance_engine.rs | 2 +- .../postgres/tests/integration/conformance_engine.rs | 10 ++++++++-- .../test-support/src/conformance/conformance_engine.rs | 4 +++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/linux/crates/drivers/mssql/tests/integration/conformance_engine.rs b/linux/crates/drivers/mssql/tests/integration/conformance_engine.rs index 8b23e7ad05..70a52bc41f 100644 --- a/linux/crates/drivers/mssql/tests/integration/conformance_engine.rs +++ b/linux/crates/drivers/mssql/tests/integration/conformance_engine.rs @@ -56,7 +56,7 @@ impl ConformanceEngine for MssqlEngine { } fn unbounded_statement_sql(&self, marker: &str) -> String { - format!("/* {marker} */ WAITFOR DELAY '23:59:59'") + format!("WAITFOR /* {marker} */ DELAY '23:59:59'") } async fn blocking_statement_sql(&self, marker: &str, hold: Duration) -> Result { diff --git a/linux/crates/drivers/mysql/tests/integration/conformance_engine.rs b/linux/crates/drivers/mysql/tests/integration/conformance_engine.rs index c9f01abb46..2f2adc900a 100644 --- a/linux/crates/drivers/mysql/tests/integration/conformance_engine.rs +++ b/linux/crates/drivers/mysql/tests/integration/conformance_engine.rs @@ -93,7 +93,7 @@ impl ConformanceEngine for MySqlEngine { /// SLEEP alone in a SELECT returns 1 when killed, with no error, so /// it sits in a WHERE clause, where a KILL fails the statement. fn unbounded_statement_sql(&self, marker: &str) -> String { - format!("SELECT 1 FROM DUAL WHERE SLEEP(3600) = 0 /* {marker} */") + format!("SELECT /* {marker} */ 1 FROM DUAL WHERE SLEEP(3600) = 0") } async fn blocking_statement_sql(&self, marker: &str, hold: Duration) -> Result { diff --git a/linux/crates/drivers/postgres/tests/integration/conformance_engine.rs b/linux/crates/drivers/postgres/tests/integration/conformance_engine.rs index 0a480d6f5b..5a66da309b 100644 --- a/linux/crates/drivers/postgres/tests/integration/conformance_engine.rs +++ b/linux/crates/drivers/postgres/tests/integration/conformance_engine.rs @@ -61,7 +61,7 @@ impl ConformanceEngine for PgEngine { } fn unbounded_statement_sql(&self, marker: &str) -> String { - format!("SELECT pg_sleep(3600) /* {marker} */") + format!("SELECT /* {marker} */ pg_sleep(3600)") } async fn blocking_statement_sql(&self, marker: &str, hold: Duration) -> Result { @@ -94,10 +94,16 @@ impl NetworkConformanceEngine for PgEngine { Ok((options, fault)) } + /// An editor run reads a row source through its cursor, so the server + /// shows the FETCH, not the statement and its marker. A FETCH asleep + /// in `pg_sleep` is the unbounded statement, since each case has the + /// server to itself. fn running_statement_count_sql(&self, marker: &str) -> String { format!( "SELECT count(*) FROM pg_stat_activity \ - WHERE state = 'active' AND query LIKE '%{marker}%' AND pid <> pg_backend_pid()" + WHERE state = 'active' AND pid <> pg_backend_pid() \ + AND (query LIKE '%{marker}%' \ + OR (query LIKE 'FETCH % FROM tablepro_cursor' AND wait_event = 'PgSleep'))" ) } } diff --git a/linux/crates/test-support/src/conformance/conformance_engine.rs b/linux/crates/test-support/src/conformance/conformance_engine.rs index aae00cd5d9..d9235d8154 100644 --- a/linux/crates/test-support/src/conformance/conformance_engine.rs +++ b/linux/crates/test-support/src/conformance/conformance_engine.rs @@ -28,7 +28,9 @@ pub trait ConformanceEngine: Send + Sync + Sized + 'static { fn create_scratch_table_sql(&self, table: &TableRef) -> String; /// A read that never finishes on its own and carries `marker` in its - /// text, so the server's own session list can find it. + /// text, so the server's own session list can find it. The marker + /// goes inside the statement: the editor's script splitter drops a + /// leading or trailing comment before the text is sent. fn unbounded_statement_sql(&self, marker: &str) -> String; /// A read that finishes after at least `hold`. From 7f28c226639b82fe1c7e0afdea36dc9cdcfb76d6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:37:24 +0700 Subject: [PATCH 071/178] refactor(linux): drop the CancelAcknowledgement phase no driver raises --- linux/crates/app/src/ui/error_text/driver.rs | 3 --- linux/crates/core/src/timeout_phase.rs | 2 -- linux/docs/error-handling.md | 1 - 3 files changed, 6 deletions(-) diff --git a/linux/crates/app/src/ui/error_text/driver.rs b/linux/crates/app/src/ui/error_text/driver.rs index 210398ab6f..3cf6fd16f0 100644 --- a/linux/crates/app/src/ui/error_text/driver.rs +++ b/linux/crates/app/src/ui/error_text/driver.rs @@ -190,9 +190,6 @@ fn timeout_message(error: &DriverError) -> String { crate::i18n::gettext("The statement ran past its time limit, so the server stopped it.") } TimeoutPhase::Probe => crate::i18n::gettext("The server did not answer a check in time."), - TimeoutPhase::CancelAcknowledgement => { - crate::i18n::gettext("The server did not confirm the stop in time, so the connection was closed.") - } } } diff --git a/linux/crates/core/src/timeout_phase.rs b/linux/crates/core/src/timeout_phase.rs index 25b52a615e..49654536c8 100644 --- a/linux/crates/core/src/timeout_phase.rs +++ b/linux/crates/core/src/timeout_phase.rs @@ -9,7 +9,6 @@ pub enum TimeoutPhase { LockWait, Statement, Probe, - CancelAcknowledgement, } impl fmt::Display for TimeoutPhase { @@ -22,7 +21,6 @@ impl fmt::Display for TimeoutPhase { Self::LockWait => "lock wait", Self::Statement => "statement", Self::Probe => "probe", - Self::CancelAcknowledgement => "cancel acknowledgement", }) } } diff --git a/linux/docs/error-handling.md b/linux/docs/error-handling.md index 425cc5fd9a..78fa668563 100644 --- a/linux/docs/error-handling.md +++ b/linux/docs/error-handling.md @@ -106,7 +106,6 @@ The engine pages ([PostgreSQL](drivers/postgresql.md), [MySQL and MariaDB](drive | `LockWait` | `WorkspaceSettings::lock_timeout`, 10 s | Another session holds a lock this statement needs. | | `Statement` | The call's deadline, or the workspace statement timeout of 60 s | The statement ran too long. `server_cancelled` says whether the server confirmed the stop. | | `Probe` | `probe_timeout`, 5 s | The server did not answer a liveness check. | -| `CancelAcknowledgement` | `cancel_grace`, 5 s | The server did not confirm a stop. No shipped driver raises it: a stop the server never confirms ends as an abandoned call, `ConnectionLost`. | ## `TlsFailure` and its remedies From 9d590c9de7166a4fda529fad5e5c8de3f8139284 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:37:30 +0700 Subject: [PATCH 072/178] fix(linux): read imported files through the capped reader --- linux/CHANGELOG.md | 1 + linux/crates/app/src/test_support.rs | 2 +- .../crates/app/src/test_support/main_loop.rs | 21 +++ linux/crates/app/src/ui/app/connection.rs | 79 +++++++++-- linux/crates/app/src/ui/app/import.rs | 80 +++++++++-- linux/crates/app/src/ui/editor/sql_file.rs | 104 ++------------ linux/crates/app/src/ui/file_read.rs | 128 ++++++++++++++++++ linux/crates/app/src/ui/import_dialog.rs | 2 +- linux/crates/app/src/ui/mod.rs | 1 + 9 files changed, 295 insertions(+), 123 deletions(-) create mode 100644 linux/crates/app/src/ui/file_read.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 9b8031f29e..864da588e8 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -43,4 +43,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CSV export did not neutralise formulas in values of a type TablePro does not recognise. - Format could put a space inside `+=`, `b'01'` or `@@version`, or between a MySQL function such as `GROUP_CONCAT` and its parenthesis, and change what the statement does. - Opening a pipe or a device as a SQL file never finished, or read past the 8 MB limit. +- Importing a CSV or a connections file from a pipe or a device never finished, or read past the size limit. - SQL run in the editor, and its values, reached the journal from the database client libraries at debug level. diff --git a/linux/crates/app/src/test_support.rs b/linux/crates/app/src/test_support.rs index 24ad7d1fe3..8df1a0bb1d 100644 --- a/linux/crates/app/src/test_support.rs +++ b/linux/crates/app/src/test_support.rs @@ -8,7 +8,7 @@ mod wait_timed_out; mod widget_lookup; pub(crate) use accessible_label::assert_labelled; -pub(crate) use main_loop::{drain_main_context, wait_until}; +pub(crate) use main_loop::{drain_main_context, finish_on_main, wait_until}; pub(crate) use memory_settings::MemorySettings; pub(crate) use signal_log::SignalLog; pub(crate) use unlabelled_widget::UnlabelledWidget; diff --git a/linux/crates/app/src/test_support/main_loop.rs b/linux/crates/app/src/test_support/main_loop.rs index c04f2f2c0c..e6cf62b18b 100644 --- a/linux/crates/app/src/test_support/main_loop.rs +++ b/linux/crates/app/src/test_support/main_loop.rs @@ -1,3 +1,6 @@ +use std::cell::RefCell; +use std::future::Future; +use std::rc::Rc; use std::time::{Duration, Instant}; use gtk4::glib; @@ -25,3 +28,21 @@ pub(crate) fn wait_until(timeout: Duration, mut condition: impl FnMut() -> bool) wake.remove(); outcome } + +/// Run `future` on the main context until it ends, or give it up once +/// `timeout` passes. +pub(crate) fn finish_on_main( + timeout: Duration, + future: impl Future + 'static, +) -> Result { + let outcome = Rc::new(RefCell::new(None)); + let slot = outcome.clone(); + let running = glib::spawn_future_local(async move { + slot.replace(Some(future.await)); + }); + if let Err(timed_out) = wait_until(timeout, || outcome.borrow().is_some()) { + running.abort(); + return Err(timed_out); + } + outcome.take().ok_or(WaitTimedOut { timeout }) +} diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index 080df39e5c..bfe1b44381 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -20,6 +20,7 @@ use tablepro_session::connect::StoreConnectionWrites; use crate::services::active_session::ActiveSession; use crate::ui::connect_dialog::{ConnectDialog, ConnectDialogInit, ConnectPrefill, ConnectSubmission, FormField}; use crate::ui::error_text::TlsRemedy; +use crate::ui::file_read::{FileReadError, read_capped_file}; use crate::ui::ssh_inputs::SshEnvironment; use super::connect_flow::{self, SubmitError}; @@ -698,18 +699,10 @@ impl App { let input = input.clone(); glib::spawn_future_local(async move { let path = file.path().unwrap_or_default(); - match file.load_contents_future().await { - Ok((bytes, _etag)) => { - let _ = input.send(AppMsg::ConnectionsFileRead { - path, - bytes: bytes.to_vec(), - }); - } - Err(error) => { - tracing::warn!(%error, path = %path.display(), "could not read the file to import"); - let _ = input.send(AppMsg::ShowToast(crate::i18n::gettext("That file could not be read."))); - } - } + let _ = input.send(match read_connections_file(&file).await { + Ok(bytes) => AppMsg::ConnectionsFileRead { path, bytes }, + Err(message) => AppMsg::ShowToast(message), + }); }); }); } @@ -1081,6 +1074,24 @@ impl App { /// The filter both file dialogs open with. Built fresh each time /// because a GtkFileFilter belongs to the dialog it is given to. +/// An export holds names, hosts and options, never secrets, so even +/// thousands of connections come to well under this. +const MAX_CONNECTIONS_FILE_BYTES: usize = 8 * 1024 * 1024; + +/// The chosen export, whole, or what to tell the user instead. +async fn read_connections_file(file: &gio::File) -> Result, String> { + read_capped_file(file, MAX_CONNECTIONS_FILE_BYTES) + .await + .map_err(|error| match error { + FileReadError::NotRegular => crate::i18n::gettext("Only regular files can be imported."), + FileReadError::TooLarge => crate::i18n::gettext("That file is too large to be a connection export."), + FileReadError::Io(error) => { + tracing::warn!(%error, "could not read the file to import"); + crate::i18n::gettext("That file could not be read.") + } + }) +} + fn json_filter() -> gtk::FileFilter { let filter = gtk::FileFilter::new(); filter.set_name(Some(&crate::i18n::gettext("JSON files"))); @@ -1152,6 +1163,50 @@ mod tests { use super::*; + fn read(path: &std::path::Path) -> Result, String> { + let file = gio::File::for_path(path); + crate::test_support::finish_on_main( + Duration::from_secs(10), + async move { read_connections_file(&file).await }, + ) + .expect("the read to end") + } + + #[gtk4::test] + fn an_import_from_a_pipe_is_refused_without_waiting_for_a_writer() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("connections.json"); + let made = std::process::Command::new("mkfifo") + .arg(&path) + .status() + .expect("mkfifo"); + assert!(made.success()); + + let refused = read(&path).expect_err("a refusal"); + + assert!(refused.contains("regular files"), "{refused}"); + } + + #[gtk4::test] + fn an_import_past_the_limit_is_refused() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("connections.json"); + std::fs::write(&path, vec![b' '; MAX_CONNECTIONS_FILE_BYTES + 1]).expect("write"); + + let refused = read(&path).expect_err("a refusal"); + + assert!(refused.contains("too large"), "{refused}"); + } + + #[gtk4::test] + fn an_export_is_read_whole() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("connections.json"); + std::fs::write(&path, br#"{"connections":[]}"#).expect("write"); + + assert_eq!(read(&path).as_deref(), Ok(&br#"{"connections":[]}"#[..])); + } + #[gtk4::test] fn a_closing_window_closes_its_session_and_stops_its_connect() { let runtime = crate::test_support::test_runtime(); diff --git a/linux/crates/app/src/ui/app/import.rs b/linux/crates/app/src/ui/app/import.rs index a89b298624..8faf29f417 100644 --- a/linux/crates/app/src/ui/app/import.rs +++ b/linux/crates/app/src/ui/app/import.rs @@ -11,7 +11,8 @@ use tablepro_core::statement::GuardedStatement; use tablepro_core::{ColumnInfo, ForeignKeyMode, WriteBatch, WriteKind, WriteReport}; use tablepro_session::{OperationError, OperationKind, Session}; -use crate::ui::import_dialog::CsvImport; +use crate::ui::file_read::{FileReadError, read_capped_file}; +use crate::ui::import_dialog::{CsvImport, MAX_CSV_FILE_BYTES}; use super::ran_statements::RanStatements; use super::{App, AppMsg}; @@ -51,25 +52,18 @@ impl App { let tasks = self.context.tasks.clone(); dialog.open(Some(&self.window), gio::Cancellable::NONE, move |outcome| { let Ok(file) = outcome else { return }; - let too_big = file - .query_info("standard::size", gio::FileQueryInfoFlags::NONE, gio::Cancellable::NONE) - .map(|info| info.size() as u64 > crate::ui::import_dialog::MAX_CSV_FILE_BYTES) - .unwrap_or(false); - if too_big { - let _ = input.send(AppMsg::ShowToast(crate::i18n::gettext( - "That file is too large to import in one go.", - ))); - return; - } let input = input.clone(); let schema = schema.clone(); let table = table.clone(); let session = session.clone(); let tasks = tasks.clone(); glib::spawn_future_local(async move { - let Ok((bytes, _etag)) = file.load_contents_future().await else { - let _ = input.send(AppMsg::ShowToast(crate::i18n::gettext("That file could not be read."))); - return; + let bytes = match read_csv_file(&file).await { + Ok(bytes) => bytes, + Err(message) => { + let _ = input.send(AppMsg::ShowToast(message)); + return; + } }; // The mapping is against the table's own columns, so the // dialog cannot open until they are read. The read runs @@ -94,7 +88,7 @@ impl App { schema, table, columns, - bytes: bytes.to_vec(), + bytes, }); }); }); @@ -178,6 +172,20 @@ impl App { } } +/// The chosen CSV, whole, or what to tell the user instead. +async fn read_csv_file(file: &gio::File) -> Result, String> { + read_capped_file(file, MAX_CSV_FILE_BYTES) + .await + .map_err(|error| match error { + FileReadError::NotRegular => crate::i18n::gettext("Only regular files can be imported."), + FileReadError::TooLarge => crate::i18n::gettext("That file is too large to import in one go."), + FileReadError::Io(error) => { + tracing::warn!(%error, "could not read the CSV to import"); + crate::i18n::gettext("That file could not be read.") + } + }) +} + async fn read_columns(session: Arc, table: TableRef) -> Result, OperationError> { session .run(OperationKind::Metadata, move |lease, options| { @@ -382,6 +390,48 @@ mod tests { assert!(message.contains("London"), "{message}"); } + fn read(path: &std::path::Path) -> Result, String> { + let file = gio::File::for_path(path); + crate::test_support::finish_on_main( + std::time::Duration::from_secs(10), + async move { read_csv_file(&file).await }, + ) + .expect("the read to end") + } + + #[gtk4::test] + fn a_pipe_is_refused_without_waiting_for_a_writer() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("people.csv"); + assert!( + std::process::Command::new("mkfifo") + .arg(&path) + .status() + .unwrap() + .success() + ); + + let refused = read(&path).unwrap_err(); + + assert!(refused.contains("regular files"), "{refused}"); + } + + #[gtk4::test] + fn a_device_is_refused_before_it_is_read() { + let refused = read(std::path::Path::new("/dev/zero")).unwrap_err(); + + assert!(refused.contains("regular files"), "{refused}"); + } + + #[gtk4::test] + fn a_csv_is_read_whole() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("people.csv"); + std::fs::write(&path, "name,city\nAda,London\n").unwrap(); + + assert_eq!(read(&path).unwrap(), b"name,city\nAda,London\n"); + } + #[test] fn an_empty_field_is_kept_as_what_the_file_said() { let rows = import_rows(&import("name,city\nGrace,\n")).unwrap(); diff --git a/linux/crates/app/src/ui/editor/sql_file.rs b/linux/crates/app/src/ui/editor/sql_file.rs index d2ca5d9aed..dbb243e3a1 100644 --- a/linux/crates/app/src/ui/editor/sql_file.rs +++ b/linux/crates/app/src/ui/editor/sql_file.rs @@ -1,5 +1,6 @@ use gtk4::glib; -use gtk4::prelude::*; + +use crate::ui::file_read::{FileReadError, read_capped_file}; /// The most a file may be before the editor refuses it. /// @@ -7,73 +8,21 @@ use gtk4::prelude::*; /// an editor. Refusing is better than a window that will not paint. const MAX_SQL_FILE_BYTES: usize = 8 * 1024 * 1024; -const READ_CHUNK_BYTES: usize = 256 * 1024; - -#[derive(Debug)] -enum ReadError { - TooLarge, - Io(glib::Error), -} - /// Read a file as text off the main thread, or say why it cannot be. pub async fn load_text(file: &gio::File) -> Result { - let info = file - .query_info_future( - "standard::type,standard::size", - gio::FileQueryInfoFlags::NONE, - glib::Priority::DEFAULT, - ) - .await; - if let Ok(info) = info { - // Opening a pipe waits for a writer that may never come, and a - // device has no end to read to. - if info.has_attribute(gio::FILE_ATTRIBUTE_STANDARD_TYPE) && info.file_type() == gio::FileType::Special { + let bytes = match read_capped_file(file, MAX_SQL_FILE_BYTES).await { + Ok(bytes) => bytes, + Err(FileReadError::NotRegular) => { return Err(crate::i18n::gettext("Only regular files can be opened in the editor.")); } - if info.has_attribute(gio::FILE_ATTRIBUTE_STANDARD_SIZE) - && u64::try_from(info.size()).is_ok_and(|size| size > MAX_SQL_FILE_BYTES as u64) - { - return Err(too_large()); + Err(FileReadError::TooLarge) => { + return Err(crate::i18n::gettext("That file is too large to open in the editor.")); } - } - let stream = file.read_future(glib::Priority::DEFAULT).await.map_err(unreadable)?; - let read = read_capped(stream.upcast_ref(), MAX_SQL_FILE_BYTES).await; - if let Err(error) = stream.close_future(glib::Priority::DEFAULT).await { - tracing::debug!(%error, "closing a file the editor read failed"); - } - let bytes = match read { - Ok(bytes) => bytes, - Err(ReadError::TooLarge) => return Err(too_large()), - Err(ReadError::Io(error)) => return Err(unreadable(error)), + Err(FileReadError::Io(error)) => return Err(unreadable(error)), }; String::from_utf8(bytes).map_err(|_| crate::i18n::gettext("That file is not UTF-8 text.")) } -/// Read to the end of the stream, giving up once it passes `limit`. -/// -/// The size a file reports is not enough: a remote file may report -/// none, and a file can grow between being measured and being read. -async fn read_capped(stream: &gio::InputStream, limit: usize) -> Result, ReadError> { - let mut contents = Vec::new(); - loop { - let chunk = stream - .read_bytes_future(READ_CHUNK_BYTES, glib::Priority::DEFAULT) - .await - .map_err(ReadError::Io)?; - if chunk.is_empty() { - return Ok(contents); - } - if contents.len() + chunk.len() > limit { - return Err(ReadError::TooLarge); - } - contents.extend_from_slice(&chunk); - } -} - -fn too_large() -> String { - crate::i18n::gettext("That file is too large to open in the editor.") -} - fn unreadable(error: glib::Error) -> String { tracing::warn!(%error, "could not read a file for the editor"); crate::i18n::gettext("That file could not be read.") @@ -84,45 +33,12 @@ mod tests { use std::time::Duration; use super::*; - use crate::test_support::wait_until; + use crate::test_support::finish_on_main; const WAIT: Duration = Duration::from_secs(10); fn load(file: gio::File) -> Result { - let outcome = std::rc::Rc::new(std::cell::RefCell::new(None)); - let slot = outcome.clone(); - glib::spawn_future_local(async move { - let loaded = load_text(&file).await; - slot.replace(Some(loaded)); - }); - wait_until(WAIT, || outcome.borrow().is_some()).expect("the load to end"); - outcome.take().expect("an outcome") - } - - fn capped(bytes: Vec, limit: usize) -> Result, ReadError> { - let stream = gio::MemoryInputStream::from_bytes(&glib::Bytes::from_owned(bytes)); - let outcome = std::rc::Rc::new(std::cell::RefCell::new(None)); - let slot = outcome.clone(); - glib::spawn_future_local(async move { - let read = read_capped(stream.upcast_ref(), limit).await; - slot.replace(Some(read)); - }); - wait_until(WAIT, || outcome.borrow().is_some()).expect("the read to end"); - outcome.take().expect("an outcome") - } - - #[gtk4::test] - fn a_stream_past_the_limit_is_refused_without_a_reported_size() { - let read = capped(vec![b'x'; READ_CHUNK_BYTES * 3], READ_CHUNK_BYTES * 2); - - assert!(matches!(read, Err(ReadError::TooLarge)), "{read:?}"); - } - - #[gtk4::test] - fn a_stream_at_the_limit_is_read_whole() { - let read = capped(vec![b'x'; READ_CHUNK_BYTES * 2], READ_CHUNK_BYTES * 2).expect("the contents"); - - assert_eq!(read.len(), READ_CHUNK_BYTES * 2); + finish_on_main(WAIT, async move { load_text(&file).await }).expect("the load to end") } #[gtk4::test] diff --git a/linux/crates/app/src/ui/file_read.rs b/linux/crates/app/src/ui/file_read.rs new file mode 100644 index 0000000000..fc007e6cab --- /dev/null +++ b/linux/crates/app/src/ui/file_read.rs @@ -0,0 +1,128 @@ +use gtk4::glib; +use gtk4::prelude::*; + +const READ_CHUNK_BYTES: usize = 256 * 1024; + +/// Why a file the user chose was not read. The caller words it, since +/// what is too large depends on what the file was for. +#[derive(Debug)] +pub(crate) enum FileReadError { + /// A pipe, a device or a socket. Opening a pipe waits for a writer + /// that may never come, and a device has no end to read to. + NotRegular, + TooLarge, + Io(glib::Error), +} + +/// Read a file whole off the main thread, giving up once it passes +/// `limit` bytes. +pub(crate) async fn read_capped_file(file: &gio::File, limit: usize) -> Result, FileReadError> { + let info = file + .query_info_future( + "standard::type,standard::size", + gio::FileQueryInfoFlags::NONE, + glib::Priority::DEFAULT, + ) + .await; + if let Ok(info) = info { + if info.has_attribute(gio::FILE_ATTRIBUTE_STANDARD_TYPE) && info.file_type() == gio::FileType::Special { + return Err(FileReadError::NotRegular); + } + if info.has_attribute(gio::FILE_ATTRIBUTE_STANDARD_SIZE) + && u64::try_from(info.size()).is_ok_and(|size| size > limit as u64) + { + return Err(FileReadError::TooLarge); + } + } + let stream = file + .read_future(glib::Priority::DEFAULT) + .await + .map_err(FileReadError::Io)?; + let read = read_capped(stream.upcast_ref(), limit).await; + if let Err(error) = stream.close_future(glib::Priority::DEFAULT).await { + tracing::debug!(%error, "closing a file that was read whole failed"); + } + read +} + +/// Read to the end of the stream, giving up once it passes `limit`. +/// +/// The size a file reports is not enough: a remote file may report +/// none, and a file can grow between being measured and being read. +async fn read_capped(stream: &gio::InputStream, limit: usize) -> Result, FileReadError> { + let mut contents = Vec::new(); + loop { + let chunk = stream + .read_bytes_future(READ_CHUNK_BYTES, glib::Priority::DEFAULT) + .await + .map_err(FileReadError::Io)?; + if chunk.is_empty() { + return Ok(contents); + } + if contents.len() + chunk.len() > limit { + return Err(FileReadError::TooLarge); + } + contents.extend_from_slice(&chunk); + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::test_support::finish_on_main; + + const WAIT: Duration = Duration::from_secs(10); + + fn capped(bytes: Vec, limit: usize) -> Result, FileReadError> { + let stream = gio::MemoryInputStream::from_bytes(&glib::Bytes::from_owned(bytes)); + finish_on_main(WAIT, async move { read_capped(stream.upcast_ref(), limit).await }).expect("the read to end") + } + + fn read(path: &std::path::Path, limit: usize) -> Result, FileReadError> { + let file = gio::File::for_path(path); + finish_on_main(WAIT, async move { read_capped_file(&file, limit).await }).expect("the read to end") + } + + #[gtk4::test] + fn a_stream_past_the_limit_is_refused_without_a_reported_size() { + let read = capped(vec![b'x'; READ_CHUNK_BYTES * 3], READ_CHUNK_BYTES * 2); + + assert!(matches!(read, Err(FileReadError::TooLarge)), "{read:?}"); + } + + #[gtk4::test] + fn a_stream_at_the_limit_is_read_whole() { + let read = capped(vec![b'x'; READ_CHUNK_BYTES * 2], READ_CHUNK_BYTES * 2).expect("the contents"); + + assert_eq!(read.len(), READ_CHUNK_BYTES * 2); + } + + #[gtk4::test] + fn a_device_is_refused_before_it_is_read() { + let refused = read(std::path::Path::new("/dev/zero"), 1024); + + assert!(matches!(refused, Err(FileReadError::NotRegular)), "{refused:?}"); + } + + #[gtk4::test] + fn a_file_reporting_more_than_the_limit_is_refused() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("big"); + std::fs::write(&path, vec![b' '; 1025]).expect("write"); + + let refused = read(&path, 1024); + + assert!(matches!(refused, Err(FileReadError::TooLarge)), "{refused:?}"); + } + + #[gtk4::test] + fn a_missing_file_is_an_io_error() { + let root = tempfile::tempdir().expect("tempdir"); + + let missing = read(&root.path().join("gone"), 1024); + + assert!(matches!(missing, Err(FileReadError::Io(_))), "{missing:?}"); + } +} diff --git a/linux/crates/app/src/ui/import_dialog.rs b/linux/crates/app/src/ui/import_dialog.rs index a612ffeacd..191d1907b4 100644 --- a/linux/crates/app/src/ui/import_dialog.rs +++ b/linux/crates/app/src/ui/import_dialog.rs @@ -15,7 +15,7 @@ use tablepro_core::import::{CsvImportOptions, CsvSheet, MAX_PREVIEW_ROWS, read_c /// decides how much the app holds at once. Refusing is better than a /// window that stops painting halfway through a file nobody meant to /// open. -pub const MAX_CSV_FILE_BYTES: u64 = 64 * 1024 * 1024; +pub const MAX_CSV_FILE_BYTES: usize = 64 * 1024 * 1024; /// How many values from the file each mapping row shows, so the user /// can see they picked the field they meant. diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 967546fe4e..7f0952fd03 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -10,6 +10,7 @@ mod editor; mod editor_font_provider; pub(crate) mod error_text; mod export_dialog; +mod file_read; mod filter_strip; mod grid; mod history_dialog; From 2bf46564c45d7ae9061e1d235e796b2cf60c39e5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:38:13 +0700 Subject: [PATCH 073/178] fix(linux-clickhouse): wait for a stopped query before the next run --- linux/CHANGELOG.md | 1 + linux/crates/drivers/clickhouse/src/cancel.rs | 11 ++++ .../drivers/clickhouse/src/editor_run.rs | 64 ++++++++++++++++-- .../drivers/clickhouse/src/editor_session.rs | 66 ++++++++++++++++--- linux/crates/drivers/clickhouse/src/errors.rs | 1 + linux/crates/drivers/clickhouse/src/lib.rs | 1 + .../drivers/clickhouse/src/lingering_query.rs | 21 ++++++ linux/docs/drivers/clickhouse.md | 2 + 8 files changed, 150 insertions(+), 17 deletions(-) create mode 100644 linux/crates/drivers/clickhouse/src/lingering_query.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 864da588e8..bc4a395075 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQLite text containing a NUL character exported as SQL that SQLite cannot read. - ClickHouse query or export with a FORMAT clause filled memory instead of failing. - ClickHouse editor tab silently lost its temporary tables and settings when the server expired its session. +- Running a ClickHouse statement right after stopping one failed because the server still held the session. - ClickHouse DateTime values showed as UTC text when the server profile or session set ISO output. - ClickHouse save with an unknown outcome could read as applied because of another table's mutation. - A new row removed with Delete stayed in the grid while the save left it out. diff --git a/linux/crates/drivers/clickhouse/src/cancel.rs b/linux/crates/drivers/clickhouse/src/cancel.rs index df41287ccc..51bc11413f 100644 --- a/linux/crates/drivers/clickhouse/src/cancel.rs +++ b/linux/crates/drivers/clickhouse/src/cancel.rs @@ -132,6 +132,17 @@ pub(crate) async fn kill_query(control: &clickhouse::Client, query_id: Uuid) -> .map_err(|error| map_error(&error)) } +/// Wait for a query told to stop to be gone from the server: `SYNC` +/// answers only once it has ended. +pub(crate) async fn wait_until_ended(control: &clickhouse::Client, query_id: Uuid) -> Result<(), DriverError> { + control + .query("KILL QUERY WHERE query_id = ? SYNC") + .bind(query_id.to_string()) + .execute() + .await + .map_err(|error| map_error(&error)) +} + fn unreachable(error: &DriverError) -> bool { matches!( error.category(), diff --git a/linux/crates/drivers/clickhouse/src/editor_run.rs b/linux/crates/drivers/clickhouse/src/editor_run.rs index 983d41c95c..b79aab8368 100644 --- a/linux/crates/drivers/clickhouse/src/editor_run.rs +++ b/linux/crates/drivers/clickhouse/src/editor_run.rs @@ -11,8 +11,9 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; use crate::call_client::whole_seconds; -use crate::cancel::{acknowledges_stop, kill_query}; +use crate::cancel::{acknowledges_stop, kill_query, wait_until_ended}; use crate::errors::{code, server_code}; +use crate::lingering_query::LingeringQuery; use crate::result_reader::{ReadEvent, ResultReader}; /// Rows per `ResultEvent::Rows`. The sink is awaited per batch, so a slow @@ -35,6 +36,7 @@ pub(crate) struct EditorRun { pub(crate) statement_timeout: Option, pub(crate) grace: Duration, pub(crate) shutdown: CancellationToken, + pub(crate) lingering: Arc, } /// What a run ended with, or the session being gone. @@ -55,6 +57,25 @@ impl EditorRun { (Some(run), Some(statement)) => Some(run.min(statement)), (run, statement) => run.or(statement), }; + let deadline = bound.map(|bound| started + bound); + let mut stop = Stop::new(options.cancel, self.shutdown.clone(), deadline, self.grace); + let previous = self.lingering.take(); + if let Some(previous) = previous { + // The wait is the run's own, under its stop and its deadline, + // so it is as long as the server takes and no longer than the + // user lets it. + tokio::select! { + interrupt = stop.triggered() => { + self.lingering.left(previous); + return Ok(summary(started, unsent(interrupt))); + } + ended = wait_until_ended(&self.control, previous) => { + if let Err(error) = ended { + tracing::debug!(%error, "waiting for a stopped editor query to end failed"); + } + } + } + } let query_id = Uuid::new_v4(); let mut query = self .session @@ -65,19 +86,17 @@ impl EditorRun { .with_setting("max_execution_time", whole_seconds(bound).to_string()) .with_setting("timeout_overflow_mode", "throw"); } - let deadline = bound.map(|bound| started + bound); let mut reader = match ResultReader::open(query) { Ok(reader) => reader, Err(error) => return Ok(summary(started, RunEnd::Failed(error))), }; let mut emitter = Emitter::new(sink, options.row_limit); - let mut stop = Stop::new(options.cancel, self.shutdown.clone(), deadline, self.grace); let end = loop { let event = tokio::select! { biased; event = reader.next_event() => event, interrupt = stop.triggered(), if stop.fired.is_none() => { - stop.fire(interrupt, &self.control, query_id); + self.fire(&mut stop, interrupt, query_id); continue; } () = stop.gave_up(), if stop.fired.is_some() => { @@ -91,7 +110,7 @@ impl EditorRun { emitter.limit_reached().await; // The rest of the result is not wanted, so the read // stops on the server before its body is dropped. - kill_within(&self.control, query_id, self.grace).await; + self.kill(query_id).await; break stop.fired.map_or(RunEnd::Completed, |_| stop.end(None)); } } @@ -99,6 +118,15 @@ impl EditorRun { emitter.finish(reader.has_result_set(), reader.written_rows()).await; break stop.fired.map_or(RunEnd::Completed, |_| stop.end(None)); } + // The server refused the query because the session still + // runs one it stopped, which the wait above did not see + // end, so this one never started. + Err(error) if server_code(&error) == Some(code::SESSION_IS_LOCKED) => { + if let Some(previous) = previous { + self.lingering.left(previous); + } + return Err(DriverError::EditorSessionBusy); + } Err(error) if stop.fired.is_some() => break stop.end(Some(&error)), Err(error) if lost(&error) => { return Err(DriverError::ConnectionLost { @@ -121,17 +149,39 @@ impl EditorRun { // The server is still sending what the driver could not // read, so the query is stopped there before its body // is dropped. - kill_within(&self.control, query_id, self.grace).await; + self.kill(query_id).await; break RunEnd::Failed(error); } Err(error) => break RunEnd::Failed(error), } if emitter.consumer_gone && stop.fired.is_none() { - stop.fire(Interrupt::ConsumerGone, &self.control, query_id); + self.fire(&mut stop, Interrupt::ConsumerGone, query_id); } }; Ok(summary(started, end)) } + + /// The server may still be ending a query told to stop when the run + /// is done with it, so the next run waits for it. + fn fire(&self, stop: &mut Stop, interrupt: Interrupt, query_id: Uuid) { + self.lingering.left(query_id); + stop.fire(interrupt, &self.control, query_id); + } + + async fn kill(&self, query_id: Uuid) { + self.lingering.left(query_id); + kill_within(&self.control, query_id, self.grace).await; + } +} + +/// How a run stopped before its query went out ends. +fn unsent(interrupt: Interrupt) -> RunEnd { + match interrupt { + Interrupt::DeadlineElapsed => RunEnd::TimedOut { + server_cancelled: false, + }, + Interrupt::Cancelled | Interrupt::ConsumerGone => RunEnd::Cancelled, + } } fn summary(started: Instant, end: RunEnd) -> RunSummary { diff --git a/linux/crates/drivers/clickhouse/src/editor_session.rs b/linux/crates/drivers/clickhouse/src/editor_session.rs index 33a78ab98d..d398a0527f 100644 --- a/linux/crates/drivers/clickhouse/src/editor_session.rs +++ b/linux/crates/drivers/clickhouse/src/editor_session.rs @@ -11,6 +11,7 @@ use uuid::Uuid; use crate::editor_run::EditorRun; use crate::endpoint::ClickHouseEndpoint; +use crate::lingering_query::LingeringQuery; use crate::result_reader::drain; use crate::session_status::SessionStatus; @@ -29,6 +30,7 @@ pub(crate) struct ClickHouseEditorSession { grace: Duration, status: Arc, shutdown: CancellationToken, + lingering: Arc, } impl ClickHouseEditorSession { @@ -63,6 +65,7 @@ impl ClickHouseEditorSession { grace: liveness.cancel_grace, status: Arc::new(SessionStatus::new()), shutdown: CancellationToken::new(), + lingering: Arc::new(LingeringQuery::default()), })) } } @@ -94,12 +97,15 @@ impl EditorSession for ClickHouseEditorSession { statement_timeout: self.statement_timeout, grace: self.grace, shutdown: self.shutdown.clone(), + lingering: self.lingering.clone(), }; let status = self.status.clone(); let running = tokio::spawn(async move { let outcome = run.run(sql, options, sink).await; match &outcome { - Ok(_) => status.finish(), + // The server still held the session for a stopped query: + // the session itself is fine. + Ok(_) | Err(DriverError::EditorSessionBusy) => status.finish(), Err(_) => status.lose(), } outcome @@ -136,7 +142,8 @@ mod tests { use async_trait::async_trait; use secrecy::SecretString; use tablepro_core::{ - ConnectEnvironment, Credentials, NetworkEndpoint, NetworkTarget, ResultEvent, RunEnd, SinkFlow, TlsConfig, + ConnectEnvironment, Credentials, LivenessPolicy, NetworkEndpoint, NetworkTarget, ResultEvent, RunEnd, SinkFlow, + TlsConfig, }; use tablepro_net::DirectTcpTransport; use tablepro_test_fixtures::ClickHouseFixture; @@ -163,11 +170,7 @@ mod tests { .await } - #[tokio::test] - #[ignore = "requires docker"] - async fn a_session_the_server_let_expire_is_lost_not_replaced() { - let fixture = ClickHouseFixture::start().await.expect("a ClickHouse container"); - let liveness = test_liveness(); + fn endpoint(fixture: &ClickHouseFixture, liveness: LivenessPolicy) -> ClickHouseEndpoint { let address = NetworkEndpoint::new(fixture.host(), fixture.http_port()).expect("an endpoint"); let credentials = fixture.password_credentials(); let target = NetworkTarget { @@ -179,9 +182,14 @@ mod tests { database: Some("tablepro".to_owned()), tls: TlsConfig::disabled(), }; - let endpoint = ClickHouseEndpoint::new(&target, liveness, &ConnectEnvironment::new("test"), false) - .expect("an endpoint") - .with_session_timeout(Duration::from_secs(1)); + ClickHouseEndpoint::new(&target, liveness, &ConnectEnvironment::new("test"), false).expect("an endpoint") + } + + #[tokio::test] + #[ignore = "requires docker"] + async fn a_session_the_server_let_expire_is_lost_not_replaced() { + let fixture = ClickHouseFixture::start().await.expect("a ClickHouse container"); + let endpoint = endpoint(&fixture, test_liveness()).with_session_timeout(Duration::from_secs(1)); let session = ClickHouseEditorSession::open(&endpoint, EditorSessionOptions::default(), CancellationToken::new()) .await @@ -200,4 +208,42 @@ mod tests { ); assert_eq!(session.state(), EditorSessionState::Lost); } + + /// The server notices a KILL inside `sleep` only once a second, so + /// with a shorter grace the stopped run gives up its response while + /// the server still holds the session, and a run sent at once would + /// be refused with 373. + #[tokio::test] + #[ignore = "requires docker"] + async fn a_run_right_after_a_stop_waits_for_the_stopped_query() { + let fixture = ClickHouseFixture::start().await.expect("a ClickHouse container"); + let liveness = LivenessPolicy { + cancel_grace: Duration::from_millis(300), + ..test_liveness() + }; + let endpoint = endpoint(&fixture, liveness); + let session = + ClickHouseEditorSession::open(&endpoint, EditorSessionOptions::default(), CancellationToken::new()) + .await + .expect("an editor session"); + let cancel = CancellationToken::new(); + let stopping = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + stopping.cancel(); + }); + + let stopped = session + .run( + UserSql::new("SELECT sleep(3)"), + RunOptions::new(cancel), + Box::new(Discard), + ) + .await; + let next = run(&session, "SELECT 1").await; + + assert_eq!(stopped.map(|summary| summary.end), Ok(RunEnd::Cancelled)); + assert_eq!(next.map(|summary| summary.end), Ok(RunEnd::Completed)); + assert_eq!(session.state(), EditorSessionState::Ready); + } } diff --git a/linux/crates/drivers/clickhouse/src/errors.rs b/linux/crates/drivers/clickhouse/src/errors.rs index e17dc431b6..4848a19e60 100644 --- a/linux/crates/drivers/clickhouse/src/errors.rs +++ b/linux/crates/drivers/clickhouse/src/errors.rs @@ -20,6 +20,7 @@ pub(crate) mod code { pub(crate) const NETWORK_ERROR: i32 = 210; pub(crate) const UNFINISHED: i32 = 341; pub(crate) const SESSION_NOT_FOUND: i32 = 372; + pub(crate) const SESSION_IS_LOCKED: i32 = 373; pub(crate) const QUERY_IS_PROHIBITED: i32 = 392; pub(crate) const QUERY_WAS_CANCELLED: i32 = 394; pub(crate) const ACCESS_DENIED: i32 = 497; diff --git a/linux/crates/drivers/clickhouse/src/lib.rs b/linux/crates/drivers/clickhouse/src/lib.rs index a36dbe1ced..c80dfb56da 100644 --- a/linux/crates/drivers/clickhouse/src/lib.rs +++ b/linux/crates/drivers/clickhouse/src/lib.rs @@ -25,6 +25,7 @@ mod errors; mod http_call; mod key_expression; mod line_reader; +mod lingering_query; mod metadata; mod param_value; mod result_reader; diff --git a/linux/crates/drivers/clickhouse/src/lingering_query.rs b/linux/crates/drivers/clickhouse/src/lingering_query.rs new file mode 100644 index 0000000000..16a5cc45fc --- /dev/null +++ b/linux/crates/drivers/clickhouse/src/lingering_query.rs @@ -0,0 +1,21 @@ +use std::sync::{Mutex, PoisonError}; + +use uuid::Uuid; + +/// The last query an editor session told the server to stop. +/// +/// The server may still be ending it after the run has given up its +/// response, and until it has, the session refuses another query with +/// 373. So the next run waits for it first. +#[derive(Debug, Default)] +pub(crate) struct LingeringQuery(Mutex>); + +impl LingeringQuery { + pub(crate) fn left(&self, query_id: Uuid) { + *self.0.lock().unwrap_or_else(PoisonError::into_inner) = Some(query_id); + } + + pub(crate) fn take(&self) -> Option { + self.0.lock().unwrap_or_else(PoisonError::into_inner).take() + } +} diff --git a/linux/docs/drivers/clickhouse.md b/linux/docs/drivers/clickhouse.md index a6796aae24..a0ffc86484 100644 --- a/linux/docs/drivers/clickhouse.md +++ b/linux/docs/drivers/clickhouse.md @@ -59,6 +59,7 @@ A stop is `KILL QUERY WHERE query_id = ? ASYNC` on the control client. Each quer | 164, 392 | `ReadOnly(ServerRejected)` | | 192, 193, 194, 516; 497 on the first request | `Auth` | | 202, 203 | `Busy` | +| 373 on an editor run | `EditorSessionBusy` | ## Writes @@ -99,4 +100,5 @@ The query log and the process list are each node's own, and the driver asks the - One statement per run (`ScriptExecution::SingleStatement`), each with a new query id. - The run's deadline, or the session's statement timeout, goes to the server as `max_execution_time` with `timeout_overflow_mode=throw`. A stop sends `KILL QUERY` and keeps reading, because the server answers the `KILL` by ending the response; a server that has not answered within the cancel grace is cut off. - Past the row limit the driver sends `KILL QUERY` for the rest of the result rather than reading it. +- The server keeps a session locked until a query it was told to stop has ended, which can be after the run gave up its response, and refuses the session's next query with 373 until then. So the run after a stop first sends `KILL QUERY ... SYNC` for the stopped query, which answers once it is gone, and waits under its own cancel and deadline. A 373 that still comes is `EditorSessionBusy`, and the session stays ready. - ClickHouse has no transaction an editor statement can leave open, so the transaction state is always `Idle`. From b82654d9d1ba2a50e73bb01dcf9b192dee50c055 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:45:06 +0700 Subject: [PATCH 074/178] fix(linux-tabs): free pages made by the tab overview's new-tab button --- linux/crates/app/src/ui/app/workspace_tabs.rs | 193 +++++++++++++----- 1 file changed, 143 insertions(+), 50 deletions(-) diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index bcc58c34b4..0034f27747 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -36,7 +36,6 @@ impl App { } fn build_workspace_root(&mut self, sender: ComponentSender) { - let workspace_tabs = self.workspace_tabs.clone(); let schema_buffer = self.schema_buffer.clone(); let settings = self.settings.clone(); let history = self.history.store(); @@ -44,41 +43,22 @@ impl App { let session = self.context.active.clone(); let hold = self.context.hold.clone(); let close_timeout = self.context.close_timeout(); - let input = sender.input_sender().clone(); - let toasts = self.toast_overlay.downgrade(); - // Numbered like the tabs `append_editor_tab` makes, so the two - // ways of opening one look the same. - let create_tab = move |tab_view: &adw::TabView| { - let tab_id = Uuid::new_v4(); - let editor = SqlEditor::new(SqlEditorInit { - schema_buffer: schema_buffer.clone(), - initial_query: None, - settings: settings.clone(), - history: history.clone(), - tasks: tasks.clone(), - session: session.clone(), - hold: hold.clone(), - close_timeout, - }); - connect_editor(&editor, tab_id, &input, &toasts); - let page = tab_view.append(&editor); - let editor_count = workspace_tabs - .borrow() - .values() - .filter(|t| matches!(t, WorkspaceTab::Editor(_))) - .count(); - page.set_title(&default_editor_tab_label(editor_count + 1)); - write_workspace_tab_id(&page, tab_id); - let slot = EditorTabSlot { - editor, - page: page.clone(), - draft: tablepro_storage::DraftId::new(), - query: String::new(), - }; - workspace_tabs.borrow_mut().insert(tab_id, WorkspaceTab::Editor(slot)); - input.emit(AppMsg::WorkspaceTabsChanged); - page + let editor_init = move || SqlEditorInit { + schema_buffer: schema_buffer.clone(), + initial_query: None, + settings: settings.clone(), + history: history.clone(), + tasks: tasks.clone(), + session: session.clone(), + hold: hold.clone(), + close_timeout, }; + let create_tab = overview_editor_tab( + self.workspace_tabs.clone(), + editor_init, + sender.input_sender().clone(), + self.toast_overlay.downgrade(), + ); let chrome = build_workspace_chrome(sender.input_sender(), create_tab); self.workspace_root = Some(chrome.overview); self.workspace_tab_view = Some(chrome.view); @@ -1269,15 +1249,12 @@ fn build_workspace_chrome( .enable_search(true) .child(&inner) .build(); - // `connect_create_tab` has to return a page, and an overview with no - // view has none to make. - overview.connect_closure( - "create-tab", - false, - glib::closure_local!(move |overview: &adw::TabOverview| -> Option { - overview.view().map(|view| create_tab(&view)) - }), - ); + // The signal returns its page (transfer none), and this binding hands + // it over without a reference of its own. A generic closure adds one, + // so every page the overview made would outlive its close. The + // overview already holds its view, so this capture adds no cycle. + let create_view = tab_view.clone(); + overview.connect_create_tab(move |_| create_tab(&create_view)); WorkspaceChrome { overview, @@ -1285,6 +1262,38 @@ fn build_workspace_chrome( } } +/// The page the overview's own new-tab button asks for: an empty editor +/// tab, numbered like the ones `append_editor_tab` makes. +fn overview_editor_tab( + workspace_tabs: Rc>>, + editor_init: impl Fn() -> SqlEditorInit + 'static, + input: relm4::Sender, + toasts: glib::WeakRef, +) -> impl Fn(&adw::TabView) -> adw::TabPage + 'static { + move |tab_view| { + let tab_id = Uuid::new_v4(); + let editor = SqlEditor::new(editor_init()); + connect_editor(&editor, tab_id, &input, &toasts); + let page = tab_view.append(&editor); + let editor_count = workspace_tabs + .borrow() + .values() + .filter(|t| matches!(t, WorkspaceTab::Editor(_))) + .count(); + page.set_title(&default_editor_tab_label(editor_count + 1)); + write_workspace_tab_id(&page, tab_id); + let slot = EditorTabSlot { + editor, + page: page.clone(), + draft: tablepro_storage::DraftId::new(), + query: String::new(), + }; + workspace_tabs.borrow_mut().insert(tab_id, WorkspaceTab::Editor(slot)); + input.emit(AppMsg::WorkspaceTabsChanged); + page + } +} + pub(super) fn qualified_browse_tab_label(schemas_count: usize, schema: Option<&str>, table: &str) -> String { if schemas_count >= 2 && let Some(s) = schema @@ -1466,27 +1475,111 @@ mod tests { view.append(>k::Label::new(None)) } + fn overview_new_tab_button(overview: &adw::TabOverview) -> gtk::Button { + crate::test_support::descendants(overview) + .into_iter() + .find_map(|widget| { + let button = widget.downcast::().ok()?; + let content = button.child()?.downcast::().ok()?; + (content.icon_name() == "adw-tab-new-symbolic").then_some(button) + }) + .expect("the overview's new-tab button") + } + + /// The chrome with the editor tabs the window's overview makes. + fn editor_chrome( + runtime: &crate::runtime::AppRuntime, + settings: &crate::test_support::MemorySettings, + tabs: &Rc>>, + toasts: &adw::ToastOverlay, + input: &relm4::Sender, + ) -> WorkspaceChrome { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let schema_buffer = crate::ui::editor::build_schema_buffer(); + let settings = settings.get().clone(); + let tasks = runtime.tasks(); + let editor_init = move || SqlEditorInit { + schema_buffer: schema_buffer.clone(), + initial_query: None, + settings: settings.clone(), + history: None, + tasks: tasks.clone(), + session: crate::services::active_session::ActiveSession::new(), + hold: Rc::new(tablepro_session::runtime::CountingHold::new()), + close_timeout: std::time::Duration::from_secs(5), + }; + let create_tab = overview_editor_tab(tabs.clone(), editor_init, input.clone(), toasts.downgrade()); + build_workspace_chrome(input, create_tab) + } + + #[gtk4::test] + fn a_closed_tab_from_the_overview_is_freed() { + let runtime = crate::test_support::test_runtime(); + let settings = crate::test_support::MemorySettings::new(); + let tabs = Rc::new(RefCell::new(HashMap::new())); + let toasts = adw::ToastOverlay::new(); + let (input, _receiver) = relm4::channel::(); + let chrome = editor_chrome(&runtime, &settings, &tabs, &toasts, &input); + toasts.set_child(Some(&chrome.overview)); + + overview_new_tab_button(&chrome.overview).emit_clicked(); + let page = chrome.view.selected_page().expect("the new-tab button made a page"); + let id = read_workspace_tab_id(&page).expect("the page names its tab"); + assert!(tabs.borrow().contains_key(&id), "the window does not know the new tab"); + let editor = page.child().downgrade(); + let freed_page = page.downgrade(); + + // What the window does once the tab may close. + chrome.view.close_page(&page); + let removed = tabs.borrow_mut().remove(&id); + if let Some(WorkspaceTab::Editor(slot)) = &removed { + slot.editor.shutdown(); + } + chrome.view.close_page_finish(&page, true); + drop((removed, page)); + drain_main_context(); + + assert!(freed_page.upgrade().is_none(), "the closed page is still alive"); + assert!(editor.upgrade().is_none(), "the closed page's editor is still alive"); + } + #[gtk4::test] fn tab_view_freed_after_teardown() { - adw::init().expect("libadwaita initialises under the test backend"); + let runtime = crate::test_support::test_runtime(); + let settings = crate::test_support::MemorySettings::new(); + let tabs = Rc::new(RefCell::new(HashMap::new())); + let toasts = adw::ToastOverlay::new(); let (input, _receiver) = relm4::channel::(); - let chrome = build_workspace_chrome(&input, blank_page); + let chrome = editor_chrome(&runtime, &settings, &tabs, &toasts, &input); let stack = gtk::Stack::new(); stack.add_named(&chrome.overview, Some("tabs")); - blank_page(&chrome.view); - let created = chrome.overview.emit_by_name::>("create-tab", &[]); - assert!(created.is_some(), "the overview's new-tab button made no page"); - drop(created); + toasts.set_child(Some(&stack)); + overview_new_tab_button(&chrome.overview).emit_clicked(); + overview_new_tab_button(&chrome.overview).emit_clicked(); assert_eq!(chrome.view.n_pages(), 2); + let selected = chrome.view.selected_page().expect("a selected page"); + let editor = selected.child().downgrade(); + let page = selected.downgrade(); + drop(selected); let view = chrome.view.downgrade(); let overview = chrome.overview.downgrade(); + // What the window does on disconnect. + for tab in tabs.borrow().values() { + if let WorkspaceTab::Editor(slot) = tab { + slot.editor.shutdown(); + } + } stack.remove(&chrome.overview); drop(chrome); + tabs.borrow_mut().clear(); drain_main_context(); assert!(view.upgrade().is_none(), "the tab view outlived its teardown"); assert!(overview.upgrade().is_none(), "the overview outlived its teardown"); + assert!(page.upgrade().is_none(), "a page outlived its teardown"); + assert!(editor.upgrade().is_none(), "an editor outlived its teardown"); } #[gtk4::test] From 66cdf84d56c97fe6c2f70e218f0dd471cb00eb6d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:45:08 +0700 Subject: [PATCH 075/178] test(linux-grid): prove a closed popover editor frees its cell --- linux/crates/app/src/ui/grid.rs | 95 +++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/linux/crates/app/src/ui/grid.rs b/linux/crates/app/src/ui/grid.rs index ed1caa1c8f..25128b2ba8 100644 --- a/linux/crates/app/src/ui/grid.rs +++ b/linux/crates/app/src/ui/grid.rs @@ -2378,6 +2378,101 @@ mod tests { assert_eq!(alive, 0, "cells outlived their column view"); } + #[derive(Clone, Copy, Debug)] + enum PopoverEditor { + Date, + Int, + Json, + } + + /// Commit through the editor's own control, which also closes it. + fn commit_popover(editor: PopoverEditor, popover: >k::Popover) { + use crate::test_support::first_descendant_of_type; + match editor { + PopoverEditor::Date => first_descendant_of_type::(popover) + .expect("the calendar") + .emit_by_name::<()>("day-selected", &[]), + PopoverEditor::Int => { + let spin = first_descendant_of_type::(popover).expect("the spin button"); + spin.set_value(42.0); + spin.emit_by_name::<()>("activate", &[]); + } + PopoverEditor::Json => { + first_descendant_of_type::(popover) + .expect("the JSON view") + .buffer() + .set_text("[1]"); + crate::test_support::descendants(popover) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.has_css_class("suggested-action")) + .expect("the Save button") + .emit_clicked(); + } + } + } + + /// Another test's window can take the focus and close an autohide + /// popover before it opens, so a dismissed one is shown again. + fn open_popover( + editor: PopoverEditor, + cell: &crate::ui::cell_editor::CellEditor, + sender: &relm4::Sender, + ) -> Option { + for _ in 0..5 { + match editor { + PopoverEditor::Date => show_calendar_popover(cell, 0, sender), + PopoverEditor::Int => show_spin_button_popover(cell, 0, sender, false), + PopoverEditor::Json => show_json_popover(cell, 0, sender), + } + let popover = crate::test_support::first_descendant_of_type::(cell)?; + let settled = crate::test_support::wait_until(std::time::Duration::from_secs(5), || { + popover.is_mapped() || !popover.is_visible() + }); + if settled.is_ok() && popover.is_mapped() { + return Some(popover); + } + if popover.parent().is_some() { + popover.unparent(); + } + POPOVER_SLOT.take(cell); + } + None + } + + #[gtk4::test] + fn a_closed_popover_editor_frees_its_cell() { + use crate::test_support::wait_until; + let timeout = std::time::Duration::from_secs(5); + let window = gtk::Window::new(); + window.present(); + for editor in [PopoverEditor::Date, PopoverEditor::Int, PopoverEditor::Json] { + let (sender, _receiver) = relm4::channel::(); + let cell = crate::ui::cell_editor::CellEditor::new(); + cell.set_text("x"); + window.set_child(Some(&cell)); + wait_until(timeout, || cell.is_mapped()).expect("the cell is drawn"); + let popover = open_popover(editor, &cell, &sender).expect("the editor popover opens"); + + commit_popover(editor, &popover); + wait_until(timeout, || popover.parent().is_none()).expect("the popover closes"); + assert_ne!(cell.text(), "x", "{editor:?} did not commit"); + assert!( + POPOVER_SLOT.take(&cell).is_none(), + "{editor:?} left its popover in the cell" + ); + // Only the cell is checked: GTK itself keeps a closed autohide + // popover alive once a child of it has taken focus. + let freed = cell.downgrade(); + window.set_child(None::<>k::Widget>); + drop((cell, popover)); + crate::test_support::drain_main_context(); + + assert!(freed.upgrade().is_none(), "{editor:?} kept its cell alive"); + } + window.destroy(); + } + #[test] fn a_tracked_draft_carries_its_insert_tint_and_error_flash() { let tracker = TabTrackerHandle::default(); From aa90a687a9779ed0b3f920a5e349c6ed4338eb1f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 17:45:09 +0700 Subject: [PATCH 076/178] test(linux-filter): prove a Between rule frees its From and To entries --- linux/crates/app/src/ui/filter_strip.rs | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/linux/crates/app/src/ui/filter_strip.rs b/linux/crates/app/src/ui/filter_strip.rs index 799cfb7820..c6ff8a901c 100644 --- a/linux/crates/app/src/ui/filter_strip.rs +++ b/linux/crates/app/src/ui/filter_strip.rs @@ -805,6 +805,52 @@ mod tests { assert!(revealer.upgrade().is_none(), "the strip outlived its owner"); } + #[gtk4::test] + fn a_between_rule_frees_its_entries() { + let mut amount = placeholder_column("amount"); + amount.column_type.kind = tablepro_core::column::ColumnKind::Integer(tablepro_core::column::IntegerKind { + width: tablepro_core::column::IntegerWidth::W32, + signed: true, + }); + let strip = build( + vec![amount], + FilterSet { + rules: vec![FilterRule { + column: "amount".to_owned(), + op: FilterOp::Between, + value: Some(FilterValue::Pair("1".to_owned(), "2".to_owned())), + }], + ..FilterSet::default() + }, + Rc::new(|_| {}), + ); + let entry = |text: &str| { + crate::test_support::descendants(&strip.widget) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|entry| entry.text() == text) + }; + let from = entry("1").expect("the From entry"); + let to = entry("2").expect("the To entry"); + from.set_text("5"); + assert_eq!( + strip.state.borrow().rules[0].value, + Some(FilterValue::Pair("5".to_owned(), "2".to_owned())) + ); + to.set_text("9"); + assert_eq!( + strip.state.borrow().rules[0].value, + Some(FilterValue::Pair("5".to_owned(), "9".to_owned())) + ); + let freed = [from.downgrade(), to.downgrade()]; + + drop((strip, from, to)); + drain_main_context(); + + let alive = freed.iter().filter(|entry| entry.upgrade().is_some()).count(); + assert_eq!(alive, 0, "the From and To entries outlived the strip"); + } + #[gtk4::test] fn an_applied_filter_rebuilds_the_rule_list() { let strip = strip_with(vec![rule("a"), rule("b")]); From b0865bcf302564381d013e14c7971a9c337f5368 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 18:22:01 +0700 Subject: [PATCH 077/178] style(linux-tabs): shorten the overview create-tab comments --- linux/crates/app/src/ui/app/workspace_tabs.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index 0034f27747..991f3eb49a 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -1249,10 +1249,9 @@ fn build_workspace_chrome( .enable_search(true) .child(&inner) .build(); - // The signal returns its page (transfer none), and this binding hands - // it over without a reference of its own. A generic closure adds one, - // so every page the overview made would outlive its close. The - // overview already holds its view, so this capture adds no cycle. + // `create-tab` returns its page (transfer none): a generic closure adds a + // reference that outlives the page's close. The overview holds the view + // already, so this capture adds no cycle. let create_view = tab_view.clone(); overview.connect_create_tab(move |_| create_tab(&create_view)); @@ -1262,8 +1261,8 @@ fn build_workspace_chrome( } } -/// The page the overview's own new-tab button asks for: an empty editor -/// tab, numbered like the ones `append_editor_tab` makes. +// Numbered like the tabs `append_editor_tab` makes, so the two ways of +// opening one look the same. fn overview_editor_tab( workspace_tabs: Rc>>, editor_init: impl Fn() -> SqlEditorInit + 'static, @@ -1486,7 +1485,6 @@ mod tests { .expect("the overview's new-tab button") } - /// The chrome with the editor tabs the window's overview makes. fn editor_chrome( runtime: &crate::runtime::AppRuntime, settings: &crate::test_support::MemorySettings, From 7468fddd7d44e9ff3393f0725331f97922c8bdef Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 18:34:05 +0700 Subject: [PATCH 078/178] fix(linux): keep sqlparser's statement text out of the logs --- linux/CHANGELOG.md | 2 +- linux/crates/app/src/logging.rs | 55 +++++++++++++++++++++++++-------- linux/docs/adding-drivers.md | 2 +- linux/docs/error-handling.md | 2 +- 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index bc4a395075..fea2ef0118 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -45,4 +45,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format could put a space inside `+=`, `b'01'` or `@@version`, or between a MySQL function such as `GROUP_CONCAT` and its parenthesis, and change what the statement does. - Opening a pipe or a device as a SQL file never finished, or read past the 8 MB limit. - Importing a CSV or a connections file from a pipe or a device never finished, or read past the size limit. -- SQL run in the editor, and its values, reached the journal from the database client libraries at debug level. +- SQL run in the editor, a typed filter or a column default, and the values in them, reached the journal at debug level. diff --git a/linux/crates/app/src/logging.rs b/linux/crates/app/src/logging.rs index 59d17c705c..43bbcf2a47 100644 --- a/linux/crates/app/src/logging.rs +++ b/linux/crates/app/src/logging.rs @@ -10,14 +10,22 @@ use tracing_subscriber::{EnvFilter, Layer}; use crate::config::Profile; -/// The client libraries under the drivers, by target prefix. Each can -/// log what a user's database was sent or answered: tokio-postgres the -/// text of every statement at debug, sqlx every statement and a slow -/// one as a warning, tiberius rows at trace and server messages as -/// errors, the clickhouse crate exceptions that quote the query, and -/// deadpool the errors of the connections it recycles. The drivers map -/// and log what they need from these libraries themselves. -const CLIENT_LIBRARIES: &[&str] = &["tokio_postgres", "deadpool", "tiberius", "sqlx", "clickhouse"]; +/// The libraries under the drivers, by target prefix. Each can log +/// what a user's database was sent or answered: tokio-postgres the text +/// of every statement at debug, sqlx every statement and a slow one as +/// a warning, tiberius rows at trace and server messages as errors, the +/// clickhouse crate exceptions that quote the query, deadpool the errors +/// of the connections it recycles, and sqlparser at debug the text it +/// parses and the tokens and literals it reads. The drivers map and log +/// what they need from these libraries themselves. +const SILENCED_LIBRARIES: &[&str] = &[ + "tokio_postgres", + "deadpool", + "tiberius", + "sqlx", + "clickhouse", + "sqlparser", +]; #[derive(Debug, ThisError)] pub enum LoggingError { @@ -89,8 +97,9 @@ pub fn init(profile: Profile) -> Result<(), LoggingError> { /// What `init` installs, writing to `writer`. /// -/// The client libraries are filtered beside `filter` rather than inside -/// it, so no `RUST_LOG` directive, however specific, turns them back on. +/// The silenced libraries are filtered beside `filter` rather than +/// inside it, so no `RUST_LOG` directive, however specific, turns them +/// back on. fn subscriber(filter: EnvFilter, format: LogFormat, writer: W) -> impl Subscriber + Send + Sync + 'static where W: for<'writer> MakeWriter<'writer> + Send + Sync + 'static, @@ -102,11 +111,11 @@ where LogFormat::Json => output.json().boxed(), LogFormat::Text => output.boxed(), }; - tracing_subscriber::registry().with(output.with_filter(filter.and(client_libraries_silenced()))) + tracing_subscriber::registry().with(output.with_filter(filter.and(libraries_silenced()))) } -fn client_libraries_silenced() -> Targets { - CLIENT_LIBRARIES +fn libraries_silenced() -> Targets { + SILENCED_LIBRARIES .iter() .fold(Targets::new().with_default(LevelFilter::TRACE), |targets, library| { targets.with_target(*library, LevelFilter::OFF) @@ -118,6 +127,8 @@ mod tests { use std::io::Write; use std::sync::{Arc, Mutex, PoisonError}; + use tablepro_core::sql_syntax::{MAX_EXPRESSION_LEN, SqlGrammar, parse_expression}; + use super::*; const SECRET: &str = "ALTER ROLE app PASSWORD 'hunter2'"; @@ -192,6 +203,8 @@ mod tests { log::debug!(target: "tokio_postgres::simple_query", "executing simple query: {SECRET}"); log::debug!(target: "tokio_postgres::prepare", "preparing query s0: {SECRET}"); log::info!(target: "tokio_postgres::connection", "NOTICE: {SECRET}"); + log::debug!(target: "sqlparser::parser", "Parsing sql '{SECRET}'..."); + log::debug!(target: "sqlparser::dialect", "get_next_precedence_full() {SECRET}"); log::debug!(target: "drivers_postgres::pool", "a pooled connection ended"); }); @@ -199,6 +212,22 @@ mod tests { assert!(text.contains("a pooled connection ended"), "{text}"); } + #[test] + fn a_filter_the_parser_reads_stays_out_of_the_logs() { + let _ = tracing_log::LogTracer::init(); + + let text = written("debug", LogFormat::Text, || { + for grammar in SqlGrammar::ALL { + let parsed = parse_expression(grammar, "api_key = 'hunter2'", MAX_EXPRESSION_LEN); + assert!(parsed.is_ok(), "{grammar:?}: {parsed:?}"); + } + tracing::debug!(target: "tablepro::ui::browse", "the filter applied"); + }); + + assert!(!text.contains("hunter2"), "{text}"); + assert!(text.contains("the filter applied"), "{text}"); + } + #[test] fn the_filter_still_drops_what_rust_log_leaves_out() { let text = written("info", LogFormat::Text, || { diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index 73f03f0914..b38fc00183 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -376,7 +376,7 @@ cargo nextest run --locked --profile ci -p tablepro-driver- --run-ignore - [ ] Saves record every step in a `WriteLedger`, and the stop is held while a commit is in flight. - [ ] Editor sessions are one connection per tab, one run at a time, with `ResultSink` backpressure and the row-limit rule. - [ ] Errors map to `DriverError` by the engine's code ([error-handling.md](error-handling.md)). -- [ ] The client library's log target is in `CLIENT_LIBRARIES` in `crates/app/src/logging.rs`, so no statement text reaches the journal ([error-handling.md](error-handling.md#logging)). +- [ ] The client library's log target is in `SILENCED_LIBRARIES` in `crates/app/src/logging.rs`, so no statement text reaches the journal ([error-handling.md](error-handling.md#logging)). - [ ] Registered in `build_registry`; added to the workspace and to `crates/app/Cargo.toml`. - [ ] Both conformance suites run; the CI matrix and nextest groups include the package. - [ ] A page in `docs/drivers/` covers timeouts, stops, read-only, writes, trust and the engine's limits. diff --git a/linux/docs/error-handling.md b/linux/docs/error-handling.md index 78fa668563..5fa81b20fc 100644 --- a/linux/docs/error-handling.md +++ b/linux/docs/error-handling.md @@ -153,7 +153,7 @@ Levels: `error!` for what the user must see or a broken contract, `warn!` for re Never log a password, a secret, a key or a bound value. sqlx's own statement logging is turned off in the MySQL and SQLite drivers, and a test in each crate (`tests/statement_logging.rs`) proves no statement text reaches the subscriber. -The client libraries under the drivers log what a database was sent or answered: tokio-postgres the text of every statement at `debug`, through `log`, which `tracing-subscriber` forwards; sqlx every statement, and a slow one as a warning; tiberius rows at `trace` and server messages as errors; the clickhouse crate exceptions that quote the query; deadpool the errors of the connections it recycles. So the subscriber drops every event whose target starts with `tokio_postgres`, `deadpool`, `tiberius`, `sqlx` or `clickhouse`, at every level. That filter sits beside the `RUST_LOG` one, so no directive turns them back on, however specific. A driver logs what it needs from their errors itself. A new driver's client library joins `CLIENT_LIBRARIES` in `logging.rs`. +The libraries under the drivers log what a database was sent or answered: tokio-postgres the text of every statement at `debug`, through `log`, which `tracing-subscriber` forwards; sqlx every statement, and a slow one as a warning; tiberius rows at `trace` and server messages as errors; the clickhouse crate exceptions that quote the query; deadpool the errors of the connections it recycles; sqlparser, also through `log`, the text of every statement, filter and default it parses, and the literals in it, at `debug`. So the subscriber drops every event whose target starts with `tokio_postgres`, `deadpool`, `tiberius`, `sqlx`, `clickhouse` or `sqlparser`, at every level. That filter sits beside the `RUST_LOG` one, so no directive turns them back on, however specific. A driver logs what it needs from their errors itself. A new driver's client library, or any other library that logs SQL text, joins `SILENCED_LIBRARIES` in `logging.rs`. ## `unwrap`, `expect` and `panic!` From 43e460dcedd0c9f9616c67ffbe35084d42f0f424 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 18:34:10 +0700 Subject: [PATCH 079/178] docs(linux): correct stale notes on imports, stops and close --- linux/CHANGELOG.md | 2 +- linux/crates/app/src/ui/app/connection.rs | 4 ++-- linux/crates/drivers/sqlite/src/editor_session.rs | 5 +++-- linux/docs/adding-drivers.md | 2 +- linux/docs/drivers/mysql.md | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index fea2ef0118..8c2686af1d 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -44,5 +44,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CSV export did not neutralise formulas in values of a type TablePro does not recognise. - Format could put a space inside `+=`, `b'01'` or `@@version`, or between a MySQL function such as `GROUP_CONCAT` and its parenthesis, and change what the statement does. - Opening a pipe or a device as a SQL file never finished, or read past the 8 MB limit. -- Importing a CSV or a connections file from a pipe or a device never finished, or read past the size limit. +- Importing a CSV or a connections file from a pipe or a device never finished, a CSV could read past its size limit, and a connections file had none. - SQL run in the editor, a typed filter or a column default, and the values in them, reached the journal at debug level. diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index bfe1b44381..c1c4c749f2 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -1072,8 +1072,6 @@ impl App { } } -/// The filter both file dialogs open with. Built fresh each time -/// because a GtkFileFilter belongs to the dialog it is given to. /// An export holds names, hosts and options, never secrets, so even /// thousands of connections come to well under this. const MAX_CONNECTIONS_FILE_BYTES: usize = 8 * 1024 * 1024; @@ -1092,6 +1090,8 @@ async fn read_connections_file(file: &gio::File) -> Result, String> { }) } +/// The filter both file dialogs open with. Built fresh each time +/// because a GtkFileFilter belongs to the dialog it is given to. fn json_filter() -> gtk::FileFilter { let filter = gtk::FileFilter::new(); filter.set_name(Some(&crate::i18n::gettext("JSON files"))); diff --git a/linux/crates/drivers/sqlite/src/editor_session.rs b/linux/crates/drivers/sqlite/src/editor_session.rs index ad5cd2787c..5fb3cb2c12 100644 --- a/linux/crates/drivers/sqlite/src/editor_session.rs +++ b/linux/crates/drivers/sqlite/src/editor_session.rs @@ -295,8 +295,9 @@ impl EditorSession for SqliteEditorSession { if let Some(flag) = shared.current.lock().unwrap_or_else(PoisonError::into_inner).take() { flag.raise(); } - // A statement that will not stop keeps the connection until its - // run gives it up at the end of the cancel grace. + // The run holds the connection until its statement ends and the + // transaction state is read. When that outlasts this wait, the + // connection closes once the session and its run are both gone. let Ok(mut held) = tokio::time::timeout(shared.cancel_grace, shared.connection.lock()).await else { return; }; diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index b38fc00183..62080fdb89 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -291,7 +291,7 @@ A run streams `ResultEvent`s into a `ResultSink`: - `CommandCompleted { rows_affected }` reports a statement that returned no rows; `None` is "not reported", not zero. - `Notice` passes on a server notice or warning. -`ResultSink::accept` is awaited before the next rows are read off the wire, so a grid that cannot keep up slows the read instead of growing a queue the size of the result. `SinkFlow::ConsumerGone` ends the run with `RunEnd::Cancelled`. PostgreSQL, SQL Server and ClickHouse then stop the statement on the server, and SQLite stops stepping it. MySQL sends no `KILL`: sqlx reads the rest of the result off the wire before the next statement. `stream_read` uses the same sink and events, without a row limit. +`ResultSink::accept` is awaited before the next rows are read off the wire, so a grid that cannot keep up slows the read instead of growing a queue the size of the result. `SinkFlow::ConsumerGone` ends the run with `RunEnd::Cancelled`. PostgreSQL, MySQL, SQL Server and ClickHouse then stop the statement on the server, and SQLite stops stepping it. `stream_read` uses the same sink and events, without a row limit. ## The row-limit rule diff --git a/linux/docs/drivers/mysql.md b/linux/docs/drivers/mysql.md index 9edaec036c..f4b949fa28 100644 --- a/linux/docs/drivers/mysql.md +++ b/linux/docs/drivers/mysql.md @@ -103,5 +103,5 @@ Whether a write can be undone depends on the table's storage engine, not on the - The editor sends one statement per run. Text with several statements runs as the server's own multi-statement batch over the text protocol, except that mysql-client `DELIMITER` lines split it where the client would, because the server does not know the directive. - The row limit is `SET SESSION sql_select_limit = `, sent when the limit changes. The server stops a plain `SELECT` there, and the set ends with `Truncation::ServerStopped` and no total. A `SELECT` with a `LIMIT` of its own, or one inside a routine, is read to its end: the rows past the limit are counted, never decoded, and the set ends with `Truncation::ReadPastLimit` and its total. - A stop sends `KILL QUERY` on the session's own control connection and gives the statement the cancel grace to end. A statement that has not ended by then ends the session, because its answer is still on the way. -- When the grid stops reading, the run ends as cancelled with no `KILL`, and sqlx reads the rest of the result off the wire before the next statement. +- When the grid stops reading, the run sends the same `KILL QUERY` and ends as cancelled. The batch already sent is read to its end, unshown, and no later batch is sent. - The transaction state after each run comes from `@@in_transaction` on MariaDB and from `performance_schema.events_transactions_current` on MySQL. On MySQL before 8.0.16, with `performance_schema` off, or for a login that cannot read it, the state is `Unknown`, and the tab's close guard warns instead of guessing. From 6fb9fef734303e70ba7dc7ef7892144853533fec Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 18:36:36 +0700 Subject: [PATCH 080/178] perf(linux-clickhouse): skip the wait after a stop the server answered --- .../drivers/clickhouse/src/editor_run.rs | 45 ++++++++++++++++++- .../drivers/clickhouse/src/editor_session.rs | 32 +++++++++++++ .../drivers/clickhouse/src/lingering_query.rs | 9 ++++ linux/docs/drivers/clickhouse.md | 2 +- 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/linux/crates/drivers/clickhouse/src/editor_run.rs b/linux/crates/drivers/clickhouse/src/editor_run.rs index b79aab8368..be25498743 100644 --- a/linux/crates/drivers/clickhouse/src/editor_run.rs +++ b/linux/crates/drivers/clickhouse/src/editor_run.rs @@ -115,6 +115,7 @@ impl EditorRun { } } Ok(ReadEvent::End) => { + self.lingering.ended(query_id); emitter.finish(reader.has_result_set(), reader.written_rows()).await; break stop.fired.map_or(RunEnd::Completed, |_| stop.end(None)); } @@ -127,7 +128,12 @@ impl EditorRun { } return Err(DriverError::EditorSessionBusy); } - Err(error) if stop.fired.is_some() => break stop.end(Some(&error)), + Err(error) if stop.fired.is_some() => { + if ended_on_server(&error) { + self.lingering.ended(query_id); + } + break stop.end(Some(&error)); + } Err(error) if lost(&error) => { return Err(DriverError::ConnectionLost { during: LossPhase::Statement, @@ -162,7 +168,8 @@ impl EditorRun { } /// The server may still be ending a query told to stop when the run - /// is done with it, so the next run waits for it. + /// gives up its response, so the next run waits for it unless the + /// response reached its end. fn fire(&self, stop: &mut Stop, interrupt: Interrupt, query_id: Uuid) { self.lingering.left(query_id); stop.fire(interrupt, &self.control, query_id); @@ -202,6 +209,19 @@ fn lost(error: &DriverError) -> bool { ) } +/// An error the server answered with, which ends the query along with +/// the response. +fn ended_on_server(error: &DriverError) -> bool { + server_code(error).is_some() + || matches!( + error, + DriverError::Timeout { + server_cancelled: true, + .. + } + ) +} + /// A body the driver gave up reading, while the server may still be /// producing it. fn unreadable(error: &DriverError) -> bool { @@ -421,6 +441,27 @@ mod tests { assert!(!unreadable(&refused)); } + #[test] + fn only_an_answer_from_the_server_frees_the_session_of_a_stopped_query() { + let killed = DriverError::reported(ServerDiagnostics::new( + Some(ServerCode::ClickHouse { code: 394 }), + "Code: 394. DB::Exception: Query was cancelled", + )); + + assert!(ended_on_server(&killed)); + assert!(ended_on_server(&DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: true, + })); + assert!(!ended_on_server(&DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: false, + })); + assert!(!ended_on_server(&DriverError::Protocol( + "a row ran past 256 MiB".to_owned() + ))); + } + fn stopped(interrupt: Interrupt) -> Stop { let mut stop = Stop::new( CancellationToken::new(), diff --git a/linux/crates/drivers/clickhouse/src/editor_session.rs b/linux/crates/drivers/clickhouse/src/editor_session.rs index d398a0527f..8c6268268d 100644 --- a/linux/crates/drivers/clickhouse/src/editor_session.rs +++ b/linux/crates/drivers/clickhouse/src/editor_session.rs @@ -246,4 +246,36 @@ mod tests { assert_eq!(next.map(|summary| summary.end), Ok(RunEnd::Completed)); assert_eq!(session.state(), EditorSessionState::Ready); } + + #[tokio::test] + #[ignore = "requires docker"] + async fn a_stop_the_server_answered_leaves_nothing_for_the_next_run_to_wait_for() { + let fixture = ClickHouseFixture::start().await.expect("a ClickHouse container"); + let liveness = LivenessPolicy { + cancel_grace: Duration::from_secs(10), + ..test_liveness() + }; + let endpoint = endpoint(&fixture, liveness); + let session = + ClickHouseEditorSession::open(&endpoint, EditorSessionOptions::default(), CancellationToken::new()) + .await + .expect("an editor session"); + let cancel = CancellationToken::new(); + let stopping = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + stopping.cancel(); + }); + + let stopped = session + .run( + UserSql::new("SELECT sleep(3)"), + RunOptions::new(cancel), + Box::new(Discard), + ) + .await; + + assert_eq!(stopped.map(|summary| summary.end), Ok(RunEnd::Cancelled)); + assert_eq!(session.lingering.take(), None); + } } diff --git a/linux/crates/drivers/clickhouse/src/lingering_query.rs b/linux/crates/drivers/clickhouse/src/lingering_query.rs index 16a5cc45fc..6564030423 100644 --- a/linux/crates/drivers/clickhouse/src/lingering_query.rs +++ b/linux/crates/drivers/clickhouse/src/lingering_query.rs @@ -15,6 +15,15 @@ impl LingeringQuery { *self.0.lock().unwrap_or_else(PoisonError::into_inner) = Some(query_id); } + /// The server ended the response, and with it the query, so the + /// session is free. + pub(crate) fn ended(&self, query_id: Uuid) { + let mut lingering = self.0.lock().unwrap_or_else(PoisonError::into_inner); + if *lingering == Some(query_id) { + *lingering = None; + } + } + pub(crate) fn take(&self) -> Option { self.0.lock().unwrap_or_else(PoisonError::into_inner).take() } diff --git a/linux/docs/drivers/clickhouse.md b/linux/docs/drivers/clickhouse.md index a0ffc86484..063ad1452c 100644 --- a/linux/docs/drivers/clickhouse.md +++ b/linux/docs/drivers/clickhouse.md @@ -100,5 +100,5 @@ The query log and the process list are each node's own, and the driver asks the - One statement per run (`ScriptExecution::SingleStatement`), each with a new query id. - The run's deadline, or the session's statement timeout, goes to the server as `max_execution_time` with `timeout_overflow_mode=throw`. A stop sends `KILL QUERY` and keeps reading, because the server answers the `KILL` by ending the response; a server that has not answered within the cancel grace is cut off. - Past the row limit the driver sends `KILL QUERY` for the rest of the result rather than reading it. -- The server keeps a session locked until a query it was told to stop has ended, which can be after the run gave up its response, and refuses the session's next query with 373 until then. So the run after a stop first sends `KILL QUERY ... SYNC` for the stopped query, which answers once it is gone, and waits under its own cancel and deadline. A 373 that still comes is `EditorSessionBusy`, and the session stays ready. +- The server keeps a session locked until a query it was told to stop has ended, which can be after the run gave up its response, and refuses the session's next query with 373 until then. So when a stopped run gave up its response before the end, as one cut off at the cancel grace, past the row limit or at a row it could not read does, the next run first sends `KILL QUERY ... SYNC` for that query, which answers once it is gone, and waits under its own cancel and deadline. A stop the server answered by ending the response leaves nothing to wait for. A 373 that still comes is `EditorSessionBusy`, and the session stays ready. - ClickHouse has no transaction an editor statement can leave open, so the transaction state is always `Idle`. From f327f8d537a64ee44ef27e7d0b764c68628a3127 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 18:37:14 +0700 Subject: [PATCH 081/178] build(linux): list wave 2's app files in POTFILES.in --- linux/po/POTFILES.in | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 8628960946..6c7305f393 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -29,8 +29,10 @@ crates/app/src/services/structure_tracker_handle.rs crates/app/src/services/structure_tracker_registry.rs crates/app/src/services/tab_registry.rs crates/app/src/services/tab_tracker_handle.rs +crates/app/src/services/touch.rs crates/app/src/services/tracker_delta.rs crates/app/src/services/tracker_edit.rs +crates/app/src/services/undo_op.rs crates/app/src/services/workspace_state.rs crates/app/src/storage.rs crates/app/src/test_support.rs @@ -104,8 +106,10 @@ crates/app/src/ui/error_text/tests.rs crates/app/src/ui/error_text/tls.rs crates/app/src/ui/error_text/tls_remedy.rs crates/app/src/ui/export_dialog.rs +crates/app/src/ui/file_read.rs crates/app/src/ui/filter_strip.rs crates/app/src/ui/grid.rs +crates/app/src/ui/handler_guard.rs crates/app/src/ui/history_dialog.rs crates/app/src/ui/icons.rs crates/app/src/ui/import_dialog.rs From b8361b5b2645dd99bd1f3d5f31636fbc27fecd40 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 19:17:33 +0700 Subject: [PATCH 082/178] fix(linux-clickhouse): bound the wait for a stopped query by the grace --- .../drivers/clickhouse/src/editor_run.rs | 97 ++++++++++++++++--- .../drivers/clickhouse/src/editor_session.rs | 12 ++- linux/docs/drivers/clickhouse.md | 2 +- 3 files changed, 93 insertions(+), 18 deletions(-) diff --git a/linux/crates/drivers/clickhouse/src/editor_run.rs b/linux/crates/drivers/clickhouse/src/editor_run.rs index be25498743..84b46fdc97 100644 --- a/linux/crates/drivers/clickhouse/src/editor_run.rs +++ b/linux/crates/drivers/clickhouse/src/editor_run.rs @@ -63,7 +63,11 @@ impl EditorRun { if let Some(previous) = previous { // The wait is the run's own, under its stop and its deadline, // so it is as long as the server takes and no longer than the - // user lets it. + // user lets it. A run with no deadline gives up at the cancel + // grace instead, so a stopped query that never ends cannot + // hold it for good; a session still locked then refuses it + // with 373. + let give_up = deadline.is_none().then(|| started + self.grace); tokio::select! { interrupt = stop.triggered() => { self.lingering.left(previous); @@ -74,6 +78,9 @@ impl EditorRun { tracing::debug!(%error, "waiting for a stopped editor query to end failed"); } } + () = reached(give_up) => { + tracing::debug!("a stopped editor query did not end within the cancel grace"); + } } } let query_id = Uuid::new_v4(); @@ -238,6 +245,14 @@ async fn kill_within(control: &clickhouse::Client, query_id: Uuid, grace: Durati } } +/// Ends at `at`, or never without one. +async fn reached(at: Option) { + match at { + Some(at) => tokio::time::sleep_until(at).await, + None => std::future::pending().await, + } +} + /// What stops a run, and when the stop was sent. struct Stop { cancel: CancellationToken, @@ -261,16 +276,10 @@ impl Stop { } async fn triggered(&self) -> Interrupt { - let deadline = async { - match self.deadline { - Some(at) => tokio::time::sleep_until(at).await, - None => std::future::pending().await, - } - }; tokio::select! { () = self.cancel.cancelled() => Interrupt::Cancelled, () = self.shutdown.cancelled() => Interrupt::Cancelled, - () = deadline => Interrupt::DeadlineElapsed, + () = reached(self.deadline) => Interrupt::DeadlineElapsed, } } @@ -285,10 +294,7 @@ impl Stop { } async fn gave_up(&self) { - match self.give_up { - Some(at) => tokio::time::sleep_until(at).await, - None => std::future::pending().await, - } + reached(self.give_up).await; } /// How a stopped run ends. The server's 394 or its own timeout is the @@ -401,12 +407,14 @@ impl Emitter { #[cfg(test)] mod tests { + use async_trait::async_trait; use tablepro_core::{ServerCode, ServerDiagnostics}; + use tokio::task::JoinHandle; use super::*; - #[tokio::test] - async fn a_kill_the_server_never_answers_gives_up_at_the_grace() { + /// A route that takes every request and never answers one. + async fn silent_route() -> (clickhouse::Client, JoinHandle<()>) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("a listener"); let address = listener.local_addr().expect("an address"); let silent = tokio::spawn(async move { @@ -415,7 +423,32 @@ mod tests { held.push(socket); } }); - let control = clickhouse::Client::default().with_url(format!("http://{address}")); + ( + clickhouse::Client::default().with_url(format!("http://{address}")), + silent, + ) + } + + /// A port nothing listens on, so a request is refused at once. + async fn closed_route() -> clickhouse::Client { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("a listener"); + let address = listener.local_addr().expect("an address"); + drop(listener); + clickhouse::Client::default().with_url(format!("http://{address}")) + } + + struct Discard; + + #[async_trait] + impl ResultSink for Discard { + async fn accept(&mut self, _event: ResultEvent) -> SinkFlow { + SinkFlow::Continue + } + } + + #[tokio::test] + async fn a_kill_the_server_never_answers_gives_up_at_the_grace() { + let (control, silent) = silent_route().await; let killing = kill_within(&control, Uuid::new_v4(), Duration::from_millis(200)); @@ -426,6 +459,40 @@ mod tests { silent.abort(); } + /// The control route never answers the `KILL ... SYNC`, and the run has + /// no deadline, so only the grace ends the wait. The statement then + /// goes out, to a session route that refuses it. + #[tokio::test] + async fn a_run_without_a_deadline_stops_waiting_for_a_stopped_query_at_the_grace() { + let (control, silent) = silent_route().await; + let lingering = Arc::new(LingeringQuery::default()); + lingering.left(Uuid::new_v4()); + let run = EditorRun { + session: closed_route().await, + control, + statement_timeout: None, + grace: Duration::from_millis(200), + shutdown: CancellationToken::new(), + lingering, + }; + + let running = run.run( + UserSql::new("SELECT 1"), + RunOptions::new(CancellationToken::new()), + Box::new(Discard), + ); + let outcome = tokio::time::timeout(Duration::from_secs(10), running).await; + + assert_eq!( + outcome.map(|ran| ran.map(|summary| summary.end)), + Ok(Err(DriverError::ConnectionLost { + during: LossPhase::Statement + })), + "the run waited past the grace for a query the server never ended" + ); + silent.abort(); + } + #[test] fn only_a_body_the_driver_could_not_read_is_killed_after_it_fails() { let refused = DriverError::reported(ServerDiagnostics::new( diff --git a/linux/crates/drivers/clickhouse/src/editor_session.rs b/linux/crates/drivers/clickhouse/src/editor_session.rs index 8c6268268d..4bc240a0c0 100644 --- a/linux/crates/drivers/clickhouse/src/editor_session.rs +++ b/linux/crates/drivers/clickhouse/src/editor_session.rs @@ -212,7 +212,9 @@ mod tests { /// The server notices a KILL inside `sleep` only once a second, so /// with a shorter grace the stopped run gives up its response while /// the server still holds the session, and a run sent at once would - /// be refused with 373. + /// be refused with 373. The next run has a deadline, which bounds its + /// wait; without one the grace would, and it is shorter than the + /// server takes here. #[tokio::test] #[ignore = "requires docker"] async fn a_run_right_after_a_stop_waits_for_the_stopped_query() { @@ -240,7 +242,13 @@ mod tests { Box::new(Discard), ) .await; - let next = run(&session, "SELECT 1").await; + let next = session + .run( + UserSql::new("SELECT 1"), + RunOptions::new(CancellationToken::new()).with_deadline(Some(Duration::from_secs(10))), + Box::new(Discard), + ) + .await; assert_eq!(stopped.map(|summary| summary.end), Ok(RunEnd::Cancelled)); assert_eq!(next.map(|summary| summary.end), Ok(RunEnd::Completed)); diff --git a/linux/docs/drivers/clickhouse.md b/linux/docs/drivers/clickhouse.md index 063ad1452c..f3b5c10b9b 100644 --- a/linux/docs/drivers/clickhouse.md +++ b/linux/docs/drivers/clickhouse.md @@ -100,5 +100,5 @@ The query log and the process list are each node's own, and the driver asks the - One statement per run (`ScriptExecution::SingleStatement`), each with a new query id. - The run's deadline, or the session's statement timeout, goes to the server as `max_execution_time` with `timeout_overflow_mode=throw`. A stop sends `KILL QUERY` and keeps reading, because the server answers the `KILL` by ending the response; a server that has not answered within the cancel grace is cut off. - Past the row limit the driver sends `KILL QUERY` for the rest of the result rather than reading it. -- The server keeps a session locked until a query it was told to stop has ended, which can be after the run gave up its response, and refuses the session's next query with 373 until then. So when a stopped run gave up its response before the end, as one cut off at the cancel grace, past the row limit or at a row it could not read does, the next run first sends `KILL QUERY ... SYNC` for that query, which answers once it is gone, and waits under its own cancel and deadline. A stop the server answered by ending the response leaves nothing to wait for. A 373 that still comes is `EditorSessionBusy`, and the session stays ready. +- The server keeps a session locked until a query it was told to stop has ended, which can be after the run gave up its response, and refuses the session's next query with 373 until then. So when a stopped run gave up its response before the end, as one cut off at the cancel grace, past the row limit or at a row it could not read does, the next run first sends `KILL QUERY ... SYNC` for that query, which answers once it is gone, and waits under its own cancel and deadline, or for the cancel grace at most when it has neither a deadline nor a statement timeout. A stop the server answered by ending the response leaves nothing to wait for. A 373 that still comes is `EditorSessionBusy`, and the session stays ready. - ClickHouse has no transaction an editor statement can leave open, so the transaction state is always `Idle`. From 68f55f169db2b911a4aa24ab0c8581c030fed919 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 19:17:34 +0700 Subject: [PATCH 083/178] test(linux): cover touch restore on draft and cell undo and redo --- .../crates/app/src/services/change_tracker.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index d3c45a4a79..7f963d9a65 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -946,6 +946,51 @@ mod tests { ); } + #[test] + fn undo_and_redo_of_a_draft_edit_put_its_touch_back() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("id"), data_col("email")]; + let draft_id = insert(&mut t, vec![Value::Null, Value::Null]); + t.track_delete(rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + let _ = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 1, + value: text("a"), + }); + let edited = vec!["delete [Int(1)]".to_owned(), format!("insert {draft_id}")]; + assert_eq!(script(&t, &columns), edited); + + let _ = t.undo(); + assert_eq!( + script(&t, &columns), + vec![format!("insert {draft_id}"), "delete [Int(1)]".to_owned()] + ); + + let _ = t.redo(); + assert_eq!(script(&t, &columns), edited); + } + + #[test] + fn undo_and_redo_of_a_cell_edit_put_its_touch_back() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("id"), data_col("name")]; + edit(&mut t, 1, "a", "b"); + t.track_delete(rk(&[Value::Int(2)]), vec![Value::Int(2), text("a")]); + edit(&mut t, 1, "a", "c"); + let edited = vec!["delete [Int(2)]".to_owned(), "update [Int(1)]".to_owned()]; + assert_eq!(script(&t, &columns), edited); + + let _ = t.undo(); + assert_eq!( + script(&t, &columns), + vec!["update [Int(1)]".to_owned(), "delete [Int(2)]".to_owned()] + ); + + let _ = t.redo(); + assert_eq!(script(&t, &columns), edited); + assert_eq!(pending(&t, 1), text("c")); + } + #[test] fn change_set_identical_across_trackers() { let columns = vec![pk_col("id"), data_col("name")]; From fbe626ee1ccdc29211eabb97ac8286843c502c29 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 19:17:35 +0700 Subject: [PATCH 084/178] ci(linux): give the app's docker tests a nextest group --- linux/.config/nextest.toml | 7 +++++++ linux/docs/testing.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/linux/.config/nextest.toml b/linux/.config/nextest.toml index e8f1c2169e..12e8477b96 100644 --- a/linux/.config/nextest.toml +++ b/linux/.config/nextest.toml @@ -6,6 +6,7 @@ clickhouse = { max-threads = 1 } ssh = { max-threads = 1 } fixtures = { max-threads = 1 } session = { max-threads = 1 } +app = { max-threads = 1 } [profile.default] # tiberius 0.13 stops reading at the first end of message, so an attention @@ -48,6 +49,12 @@ test-group = 'fixtures' filter = 'package(tablepro-session)' test-group = 'session' +# The app's docker tests are integration targets. Its unit tests start +# no container, so they keep running in parallel. +[[profile.default.overrides]] +filter = 'package(tablepro) & kind(test)' +test-group = 'app' + [profile.ci] fail-fast = false # A hung test fails with its name after five minutes. Without a bound it diff --git a/linux/docs/testing.md b/linux/docs/testing.md index 94ae8cb870..dd4c37ba65 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -158,7 +158,7 @@ CI runs one job per package with docker tests: cargo nextest run --locked --profile ci -p tablepro-driver-mssql --run-ignored only ``` -`.config/nextest.toml` puts each driver's tests in a test group with one thread, so the tests of one suite never compete for a container. The `ci` profile writes a JUnit report to `target/nextest/ci/junit.xml` and kills a test that runs past five minutes, so a hang fails with the test's name. +`.config/nextest.toml` puts each package's docker tests in a test group with one thread, so the tests of one suite never compete for a container. The `ci` profile writes a JUnit report to `target/nextest/ci/junit.xml` and kills a test that runs past five minutes, so a hang fails with the test's name. Locally, with [cargo-nextest](https://nexte.st/) installed: From 7740f56b3681dedd7e5fac80238fbdf112dcc0f0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 19:17:35 +0700 Subject: [PATCH 085/178] docs(linux): list every docker job in the agent guide --- linux/AGENTS.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/linux/AGENTS.md b/linux/AGENTS.md index 68ae667764..2dd063cec0 100644 --- a/linux/AGENTS.md +++ b/linux/AGENTS.md @@ -54,4 +54,9 @@ From the repository root, through the verification wrapper, which keeps the full ## CI -`.github/workflows/build-linux.yml` runs on a pull request that touches `linux/`: **Fast checks** (format, both clippy runs, build, every Meson suite, the Secret Service tests, `meson dist`), **Translations** (the POT template, with gettext 0.24) and **Docker tests**, one job per driver and SSH package. +`.github/workflows/build-linux.yml` runs on a pull request that touches `linux/`: + +- **Fast checks**: format, both clippy runs, build, every Meson suite, the Secret Service tests, `meson dist`. +- **Translations**: the POT template, with gettext 0.24. +- **Docker tests** (`integration`): one matrix job per package on the host runner, for the PostgreSQL, MySQL, SQL Server and ClickHouse drivers, `tablepro-ssh` and `tablepro-test-fixtures`. +- **Docker tests (tablepro-session)** (`integration-session`) and **Docker tests (tablepro)** (`integration-app`): in the fast job's `ubuntu:26.04` image with the host's Docker socket, because the session crate links GLib 2.88 and the app GTK 4.22, which the host runner lacks. The app job runs only its named test targets, since the `startup` test needs Meson. From 1a1e0ac93dd0f762e75ea62a034549be2aa4c1d8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 19:23:18 +0700 Subject: [PATCH 086/178] feat(linux): add a request sequencer for a view's own fetches --- .../crates/app/src/runtime/failed_requests.rs | 61 +++++++ linux/crates/app/src/runtime/mod.rs | 10 ++ .../app/src/runtime/request_sequencer.rs | 162 ++++++++++++++++++ linux/crates/app/src/runtime/request_stamp.rs | 10 ++ .../crates/app/src/runtime/request_ticket.rs | 11 ++ linux/crates/app/src/runtime/stamped.rs | 8 + linux/po/POTFILES.in | 5 + 7 files changed, 267 insertions(+) create mode 100644 linux/crates/app/src/runtime/failed_requests.rs create mode 100644 linux/crates/app/src/runtime/request_sequencer.rs create mode 100644 linux/crates/app/src/runtime/request_stamp.rs create mode 100644 linux/crates/app/src/runtime/request_ticket.rs create mode 100644 linux/crates/app/src/runtime/stamped.rs diff --git a/linux/crates/app/src/runtime/failed_requests.rs b/linux/crates/app/src/runtime/failed_requests.rs new file mode 100644 index 0000000000..bd75b25be1 --- /dev/null +++ b/linux/crates/app/src/runtime/failed_requests.rs @@ -0,0 +1,61 @@ +use std::collections::BTreeSet; + +/// The kinds of request whose last accepted reply was a failure, which +/// a view asks again for once the connection comes back. +#[derive(Debug, Clone)] +pub struct FailedRequests { + kinds: BTreeSet, +} + +impl FailedRequests { + pub fn new() -> Self { + Self { kinds: BTreeSet::new() } + } + + /// Note how the request's last reply went. + pub fn record(&mut self, kind: K, outcome: &Result) { + match outcome { + Ok(_) => self.kinds.remove(&kind), + Err(_) => self.kinds.insert(kind), + }; + } + + pub fn contains(&self, kind: K) -> bool { + self.kinds.contains(&kind) + } + + pub fn is_empty(&self) -> bool { + self.kinds.is_empty() + } + + /// Every failed kind, in order, leaving none behind. The caller asks + /// for each again, and the replies record what happens next. + pub fn take(&mut self) -> Vec { + std::mem::take(&mut self.kinds).into_iter().collect() + } +} + +impl Default for FailedRequests { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_failure_is_kept_until_the_kind_succeeds() { + let mut failed = FailedRequests::new(); + + failed.record(2, &Err::<(), ()>(())); + failed.record(1, &Err::<(), ()>(())); + assert!(failed.contains(2)); + failed.record(2, &Ok::<(), ()>(())); + + assert!(!failed.contains(2)); + assert_eq!(failed.take(), [1]); + assert!(failed.is_empty()); + } +} diff --git a/linux/crates/app/src/runtime/mod.rs b/linux/crates/app/src/runtime/mod.rs index d8815e78e2..5763e2b4ea 100644 --- a/linux/crates/app/src/runtime/mod.rs +++ b/linux/crates/app/src/runtime/mod.rs @@ -1,7 +1,17 @@ mod app_runtime; mod deliver; +mod failed_requests; mod gio_process_hold; +mod request_sequencer; +mod request_stamp; +mod request_ticket; +mod stamped; pub use app_runtime::AppRuntime; pub use deliver::spawn_and_deliver; +pub use failed_requests::FailedRequests; pub use gio_process_hold::{GioProcessHold, hold_default_application}; +pub use request_sequencer::RequestSequencer; +pub use request_stamp::RequestStamp; +pub use request_ticket::RequestTicket; +pub use stamped::Stamped; diff --git a/linux/crates/app/src/runtime/request_sequencer.rs b/linux/crates/app/src/runtime/request_sequencer.rs new file mode 100644 index 0000000000..906aea877e --- /dev/null +++ b/linux/crates/app/src/runtime/request_sequencer.rs @@ -0,0 +1,162 @@ +use std::collections::HashMap; +use std::hash::Hash; + +use tokio_util::sync::CancellationToken; + +use super::{RequestStamp, RequestTicket}; + +/// Keeps one request of each kind current. +/// +/// Issuing a request stops the one of its kind still running, so the +/// server drops work nobody will look at, and only the reply to the +/// latest request is accepted. Dropping the sequencer stops them all, +/// which is what closing a view should do. +#[derive(Debug)] +pub struct RequestSequencer { + last: u64, + current: HashMap, +} + +impl RequestSequencer { + pub fn new() -> Self { + Self { + last: 0, + current: HashMap::new(), + } + } + + pub fn issue(&mut self, kind: K) -> RequestTicket { + self.last += 1; + let stamp = RequestStamp::new(self.last); + let cancel = CancellationToken::new(); + if let Some((_, previous)) = self.current.insert(kind, (stamp, cancel.clone())) { + previous.cancel(); + } + RequestTicket { stamp, cancel } + } + + /// Whether a reply is the answer to the current request of its kind. + /// Accepting it ends that request, so the same reply twice, or a + /// reply to one a newer request replaced, is refused. + pub fn accept(&mut self, kind: K, stamp: RequestStamp) -> bool { + match self.current.get(&kind) { + Some((current, _)) if *current == stamp => { + self.current.remove(&kind); + true + } + _ => false, + } + } + + pub fn is_current(&self, kind: K, stamp: RequestStamp) -> bool { + self.current.get(&kind).is_some_and(|(current, _)| *current == stamp) + } + + /// Whether a request of this kind is still waiting for its reply. + pub fn is_pending(&self, kind: K) -> bool { + self.current.contains_key(&kind) + } + + pub fn cancel_all(&mut self) { + for (_, (_, cancel)) in self.current.drain() { + cancel.cancel(); + } + } +} + +impl Default for RequestSequencer { + fn default() -> Self { + Self::new() + } +} + +impl Drop for RequestSequencer { + fn drop(&mut self) { + for (_, (_, cancel)) in self.current.drain() { + cancel.cancel(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + enum Kind { + Page, + Count, + } + + #[test] + fn issue_cancels_previous_token_of_same_kind() { + let mut sequencer = RequestSequencer::new(); + let first = sequencer.issue(Kind::Page); + let count = sequencer.issue(Kind::Count); + + let second = sequencer.issue(Kind::Page); + + assert!(first.cancel.is_cancelled()); + assert!(!second.cancel.is_cancelled()); + assert!(!count.cancel.is_cancelled(), "a page request stopped the count"); + assert!(second.stamp > first.stamp); + } + + #[test] + fn accept_rejects_superseded_stamp() { + let mut sequencer = RequestSequencer::new(); + let first = sequencer.issue(Kind::Page); + let second = sequencer.issue(Kind::Page); + + assert!(!sequencer.accept(Kind::Page, first.stamp)); + assert!(sequencer.is_current(Kind::Page, second.stamp)); + assert!(sequencer.accept(Kind::Page, second.stamp)); + } + + #[test] + fn duplicate_reply_dropped() { + let mut sequencer = RequestSequencer::new(); + let ticket = sequencer.issue(Kind::Count); + + assert!(sequencer.accept(Kind::Count, ticket.stamp)); + assert!(!sequencer.accept(Kind::Count, ticket.stamp)); + assert!(!sequencer.is_pending(Kind::Count)); + assert!( + !ticket.cancel.is_cancelled(), + "accepting a reply stopped the request it answered" + ); + } + + #[test] + fn a_stamp_is_only_current_for_its_own_kind() { + let mut sequencer = RequestSequencer::new(); + let page = sequencer.issue(Kind::Page); + + assert!(!sequencer.accept(Kind::Count, page.stamp)); + assert!(sequencer.is_pending(Kind::Page)); + } + + #[test] + fn cancel_all_stops_every_kind() { + let mut sequencer = RequestSequencer::new(); + let page = sequencer.issue(Kind::Page); + let count = sequencer.issue(Kind::Count); + + sequencer.cancel_all(); + + assert!(page.cancel.is_cancelled() && count.cancel.is_cancelled()); + assert!(!sequencer.accept(Kind::Page, page.stamp)); + } + + #[test] + fn drop_cancels_all() { + let mut sequencer = RequestSequencer::new(); + let page = sequencer.issue(Kind::Page); + let count = sequencer.issue(Kind::Count); + + drop(sequencer); + + assert!(page.cancel.is_cancelled()); + assert!(count.cancel.is_cancelled()); + } +} diff --git a/linux/crates/app/src/runtime/request_stamp.rs b/linux/crates/app/src/runtime/request_stamp.rs new file mode 100644 index 0000000000..da8f860aed --- /dev/null +++ b/linux/crates/app/src/runtime/request_stamp.rs @@ -0,0 +1,10 @@ +/// Which request a reply answers. Each one issued is newer than every +/// one before it, so a reply to an older request can be told apart. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RequestStamp(u64); + +impl RequestStamp { + pub(super) fn new(value: u64) -> Self { + Self(value) + } +} diff --git a/linux/crates/app/src/runtime/request_ticket.rs b/linux/crates/app/src/runtime/request_ticket.rs new file mode 100644 index 0000000000..6130772ff6 --- /dev/null +++ b/linux/crates/app/src/runtime/request_ticket.rs @@ -0,0 +1,11 @@ +use tokio_util::sync::CancellationToken; + +use super::RequestStamp; + +/// What a request carries while it runs: the stamp its reply comes back +/// with, and the token that stops it once a newer request replaces it. +#[derive(Debug, Clone)] +pub struct RequestTicket { + pub stamp: RequestStamp, + pub cancel: CancellationToken, +} diff --git a/linux/crates/app/src/runtime/stamped.rs b/linux/crates/app/src/runtime/stamped.rs new file mode 100644 index 0000000000..f21b4ea83d --- /dev/null +++ b/linux/crates/app/src/runtime/stamped.rs @@ -0,0 +1,8 @@ +use super::RequestStamp; + +/// A reply and the request it answers. +#[derive(Debug, Clone)] +pub struct Stamped { + pub stamp: RequestStamp, + pub value: T, +} diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 6c7305f393..e9ab4aa0f9 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -11,8 +11,13 @@ crates/app/src/persistence/mod.rs crates/app/src/persistence/state_file.rs crates/app/src/runtime/app_runtime.rs crates/app/src/runtime/deliver.rs +crates/app/src/runtime/failed_requests.rs crates/app/src/runtime/gio_process_hold.rs crates/app/src/runtime/mod.rs +crates/app/src/runtime/request_sequencer.rs +crates/app/src/runtime/request_stamp.rs +crates/app/src/runtime/request_ticket.rs +crates/app/src/runtime/stamped.rs crates/app/src/services/active_session.rs crates/app/src/services/cell_overlay.rs crates/app/src/services/change_tracker.rs From dc08a168b84f9ac164b2b5905be704d5f8405634 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 19:25:26 +0700 Subject: [PATCH 087/178] feat(linux-session): share one schema catalog per session --- linux/crates/app/src/test_support.rs | 2 + .../app/src/test_support/fake_session.rs | 37 + linux/crates/app/src/ui/app/connection.rs | 139 +++- linux/crates/app/src/ui/app/mod.rs | 21 +- linux/crates/app/src/ui/app/structure.rs | 45 +- .../session/src/health/health_monitor.rs | 47 +- linux/crates/session/src/lib.rs | 2 +- .../session/src/operation/operation_error.rs | 13 +- .../session/src/session/catalog_snapshot.rs | 16 - linux/crates/session/src/session/mod.rs | 8 +- .../src/session/object_list_snapshot.rs | 30 + .../session/src/session/schema_catalog.rs | 704 ++++++++++++++++++ linux/crates/session/src/session/session.rs | 63 +- .../crates/session/tests/session_postgres.rs | 64 ++ linux/po/POTFILES.in | 1 + 15 files changed, 1085 insertions(+), 107 deletions(-) create mode 100644 linux/crates/app/src/test_support/fake_session.rs delete mode 100644 linux/crates/session/src/session/catalog_snapshot.rs create mode 100644 linux/crates/session/src/session/object_list_snapshot.rs create mode 100644 linux/crates/session/src/session/schema_catalog.rs diff --git a/linux/crates/app/src/test_support.rs b/linux/crates/app/src/test_support.rs index 8df1a0bb1d..513597496d 100644 --- a/linux/crates/app/src/test_support.rs +++ b/linux/crates/app/src/test_support.rs @@ -1,4 +1,5 @@ mod accessible_label; +mod fake_session; mod main_loop; mod memory_settings; mod signal_log; @@ -8,6 +9,7 @@ mod wait_timed_out; mod widget_lookup; pub(crate) use accessible_label::assert_labelled; +pub(crate) use fake_session::FakeSession; pub(crate) use main_loop::{drain_main_context, finish_on_main, wait_until}; pub(crate) use memory_settings::MemorySettings; pub(crate) use signal_log::SignalLog; diff --git a/linux/crates/app/src/test_support/fake_session.rs b/linux/crates/app/src/test_support/fake_session.rs new file mode 100644 index 0000000000..451c285f3e --- /dev/null +++ b/linux/crates/app/src/test_support/fake_session.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use tablepro_core::LivenessPolicy; +use tablepro_session::fakes::{FakeConnector, FakeCredentialSource, FakeEstablish}; +use tablepro_session::operation::DefaultDeadlines; +use tablepro_session::runtime::Tasks; +use tablepro_session::{Session, SessionDeps}; +use tablepro_test_support::{ConnectionScript, FakeConnection}; + +/// A session over a fake connection, and what each reconnect gets. +pub(crate) struct FakeSession { + pub session: Arc, + pub connection: Arc, +} + +impl FakeSession { + pub(crate) fn start( + script: ConnectionScript, + reconnects: impl IntoIterator, + tasks: Tasks, + ) -> Self { + let request = FakeConnector::request().expect("a request"); + let (pending, connection) = FakeConnector::pending_session(request, script); + let connector = FakeConnector::new(reconnects); + let session = Session::start( + pending, + SessionDeps { + connector: Arc::new(connector), + credentials: Arc::new(FakeCredentialSource::new()), + deadlines: Arc::new(DefaultDeadlines::new(LivenessPolicy::DESKTOP)), + tasks, + prompter: None, + }, + ); + Self { session, connection } + } +} diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index c1c4c749f2..3504cb5e72 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -4,12 +4,12 @@ use relm4::adw::prelude::*; use relm4::gtk::{gio, glib}; use relm4::{ComponentController, ComponentSender, adw, gtk}; -use tablepro_core::meta::ObjectInfo; +use tablepro_core::meta::{ObjectInfo, ObjectScope}; use tablepro_session::connect::AttemptGeneration; use tablepro_session::runtime::{ProcessHold, WatchForwarder}; use tablepro_session::{ - ClaimError, ConnectAttempts, ConnectError, CredentialSource, MonitorSignal, PendingSession, Session, SessionClaim, - SessionHealth, SessionKey, + ClaimError, ConnectAttempts, ConnectError, CredentialSource, MonitorSignal, ObjectListSnapshot, PendingSession, + Session, SessionClaim, SessionHealth, SessionKey, }; use tablepro_storage::SavedConnection; use uuid::Uuid; @@ -77,13 +77,39 @@ pub(crate) struct SessionLinks { key: SessionKey, _claim: SessionClaim, _health: WatchForwarder, - _catalog: WatchForwarder, + /// Feeds the sidebar and the editor's completion words. Dropped + /// before the session shuts down, so a list read for a session the + /// window let go of never repaints it. + _catalog_watch: WatchForwarder, } fn spawn_local(future: futures::future::LocalBoxFuture<'static, ()>) { glib::spawn_future_local(future); } +/// Deliver each new object list the session's catalog commits for the +/// sidebar's scope. The list on screen when the watch starts is not +/// delivered again, and a refresh that failed keeps the list it had. +pub(super) fn catalog_watch( + session: &Session, + mut deliver: impl FnMut(ObjectListSnapshot) + 'static, +) -> WatchForwarder { + let objects = session.subscribe_objects(ObjectScope::DefaultSchema); + let mut shown = objects.borrow().generation; + WatchForwarder::new( + objects, + move |snapshot: ObjectListSnapshot| { + if snapshot.generation != shown { + shown = snapshot.generation; + deliver(snapshot); + } else if let Some(error) = &snapshot.last_error { + tracing::warn!(%error, "the table list could not be read again"); + } + }, + spawn_local, + ) +} + impl App { pub(super) fn on_open_connect(&mut self, sender: ComponentSender) { self.present_connect_dialog(None, None, sender); @@ -268,11 +294,9 @@ impl App { claim.attach(&session); self.context.active.replace(session.clone()); - let catalog = session.catalog(); - let first = catalog.borrow().clone(); + let first = session.subscribe_objects(ObjectScope::DefaultSchema).borrow().clone(); let health_sender = sender.clone(); let catalog_sender = sender.clone(); - let mut shown = first.generation; self.session_links = Some(SessionLinks { key, _claim: claim, @@ -281,18 +305,9 @@ impl App { move |health| health_sender.input(AppMsg::HealthChanged(health)), spawn_local, ), - // A reconnect reads the catalog again; the first one is - // already on screen. - _catalog: WatchForwarder::new( - catalog, - move |snapshot: tablepro_session::CatalogSnapshot| { - if snapshot.generation != shown { - shown = snapshot.generation; - catalog_sender.input(AppMsg::CatalogChanged(snapshot.objects.to_vec())); - } - }, - spawn_local, - ), + _catalog_watch: catalog_watch(&session, move |snapshot| { + catalog_sender.input(AppMsg::CatalogChanged(snapshot)); + }), }); for warning in warnings { self.show_toast(&warning); @@ -369,11 +384,8 @@ impl App { // a tab via sidebar click or Ctrl+T. self.ensure_workspace_root(sender.clone()); self.content_holder.set_content(Some(&self.workspace_outer_stack)); - let browsable = browsable(objects); - self.table_names = browsable.iter().map(|object| object.name.clone()).collect(); - tracing::info!(driver = %driver_id, table_count = browsable.len(), "workspace ready"); - self.repopulate_sidebar(&browsable); - self.rebuild_schema_buffer(); + tracing::info!(driver = %driver_id, table_count = objects.len(), "workspace ready"); + self.show_objects(&objects); self.refresh_window_title(); // Restore tabs (browse + editor) persisted from the prior session // for this connection. @@ -816,9 +828,17 @@ impl App { }); } - /// Only what the grid can page through goes in the sidebar. - pub(super) fn repopulate_sidebar(&mut self, objects: &[ObjectInfo]) { + /// The session's objects in the sidebar and among the editor's + /// completion words. + pub(super) fn show_objects(&mut self, objects: &[ObjectInfo]) { let objects = browsable(objects.to_vec()); + self.table_names = objects.iter().map(|object| object.name.clone()).collect(); + self.repopulate_sidebar(objects); + self.rebuild_schema_buffer(); + } + + /// Only what the grid can page through goes in the sidebar. + fn repopulate_sidebar(&mut self, objects: Vec) { { let mut schemas = self.sidebar_schemas.borrow_mut(); schemas.clear(); @@ -1153,15 +1173,19 @@ fn failed_banner_text(cause: tablepro_session::FailureCause) -> String { mod tests { use std::time::Duration; + use std::cell::RefCell; + use std::rc::Rc; use tablepro_core::LivenessPolicy; use tablepro_core::meta::ObjectKind; use tablepro_session::fakes::{FakeConnector, FakeCredentialSource}; use tablepro_session::operation::DefaultDeadlines; use tablepro_session::runtime::CountingHold; + use tablepro_session::{FailureCause, SessionDeps}; - use tablepro_test_support::ConnectionScript; + use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; use super::*; + use crate::test_support::FakeSession; fn read(path: &std::path::Path) -> Result, String> { let file = gio::File::for_path(path); @@ -1244,6 +1268,67 @@ mod tests { assert!(connection.is_closed(), "the connection was cut off rather than closed"); } + fn names(snapshot: &ObjectListSnapshot) -> Vec { + snapshot.objects.iter().map(|object| object.name.clone()).collect() + } + + #[gtk4::test] + fn late_catalog_commit_after_disconnect_leaves_sidebar_untouched() { + let runtime = crate::test_support::test_runtime(); + let mut first = ConnectionScript::default(); + first.list_objects.always(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(vec![object("from_a", ObjectKind::Table)]), + }); + let a = FakeSession::start(first, [], runtime.tasks()); + let mut second = ConnectionScript::default(); + second + .list_objects + .always(FakeReply::ok(vec![object("from_b", ObjectKind::Table)])); + let b = FakeSession::start(second, [], runtime.tasks()); + let sidebar: Rc>> = Rc::default(); + let show = |sidebar: &Rc>>| { + let sidebar = sidebar.clone(); + move |snapshot: ObjectListSnapshot| *sidebar.borrow_mut() = names(&snapshot) + }; + let watching_a = catalog_watch(&a.session, show(&sidebar)); + let late = { + let session = a.session.clone(); + runtime + .tasks() + .spawn_task(async move { session.refresh_objects(ObjectScope::DefaultSchema).await }) + }; + let counters = a.connection.counters(); + crate::test_support::wait_until(Duration::from_secs(5), || { + CallCounters::get(&counters.list_objects) == 1 + }) + .expect("A's list was asked for"); + + // Disconnect A and adopt B, in the order the window does. + drop(watching_a); + drop(a.session.begin_shutdown()); + let _watching_b = catalog_watch(&b.session, show(&sidebar)); + let refreshed_b = { + let session = b.session.clone(); + runtime + .tasks() + .spawn_task(async move { session.refresh_objects(ObjectScope::DefaultSchema).await }) + }; + crate::test_support::finish_on_main(Duration::from_secs(5), async move { + let _ = refreshed_b.await; + let _ = late.await; + }) + .expect("both lists to land"); + crate::test_support::drain_main_context(); + + assert_eq!( + names(&a.session.subscribe_objects(ObjectScope::DefaultSchema).borrow()), + ["from_a"], + "A's late list never landed, so the test proved nothing" + ); + assert_eq!(*sidebar.borrow(), ["from_b"]); + } + fn object(name: &str, kind: ObjectKind) -> ObjectInfo { ObjectInfo { schema: Some("public".to_owned()), diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 5923ef044a..8d0770e868 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -17,9 +17,8 @@ use relm4::gtk::{gio, glib}; use relm4::prelude::*; use relm4::{Controller, adw, gtk}; -use tablepro_core::meta::ObjectInfo; use tablepro_core::{ColumnInfo, QueryResult}; -use tablepro_session::SessionHealth; +use tablepro_session::{ObjectListSnapshot, SessionHealth}; use tablepro_storage::SavedConnection; use uuid::Uuid; @@ -352,8 +351,9 @@ pub enum AppMsg { RetryConnection, /// The adopted session's health moved. HealthChanged(SessionHealth), - /// A reconnect read the catalog again. - CatalogChanged(Vec), + /// The session's object list for the sidebar changed: a reconnect + /// read it again, or a schema change refreshed it. + CatalogChanged(ObjectListSnapshot), DialogClosed, SelectTable { schema: Option, @@ -600,17 +600,13 @@ pub enum AppMsg { /// Mirrors `BrowseTabDirtyChanged` for the title prefix and the /// AdwTabPage::set_needs_attention background-tab indicator. StructureTabDirtyChanged(Uuid, bool), - /// Schema state changed (table created / dropped / altered) — App - /// refreshes the sidebar and any open Browse tabs for the affected - /// table. + /// Schema state changed (table created / dropped / altered): the + /// catalog reads the sidebar's list again and drops the table's + /// columns, and the tabs open on the table read it again. SchemaChanged { schema: Option, table: Option, }, - /// Result of `list_tables` after a SchemaChanged event. Rebuilds - /// the sidebar factory without going through the full Connected - /// path. - TablesReloaded(Vec), /// Ctrl+Shift+T → pop the most recent closed-tab descriptor and /// reopen it. No-op when the stack is empty (e.g. no tabs closed /// yet, or just reconnected). Editor tabs come back with their @@ -1425,7 +1421,7 @@ impl SimpleComponent for App { AppMsg::EditConnection(retry) => self.on_edit_connection(*retry, sender), AppMsg::HealthChanged(health) => self.refresh_health_banner(&health), AppMsg::RetryConnection => self.on_retry_connection(), - AppMsg::CatalogChanged(objects) => self.on_tables_reloaded(objects), + AppMsg::CatalogChanged(snapshot) => self.on_catalog_changed(&snapshot), AppMsg::Disconnect => self.on_disconnect(sender), AppMsg::ForceDisconnect => self.do_disconnect(sender), AppMsg::DialogClosed => self.dialog = None, @@ -1545,7 +1541,6 @@ impl SimpleComponent for App { AppMsg::StructureLoadFailed { tab_id, message } => self.on_structure_load_failed(tab_id, message), AppMsg::StructureTabDirtyChanged(tab_id, dirty) => self.refresh_structure_tab_dirty(tab_id, dirty), AppMsg::SchemaChanged { schema, table } => self.on_schema_changed(schema, table, sender), - AppMsg::TablesReloaded(tables) => self.on_tables_reloaded(tables), AppMsg::ReloadConnections => self.on_reload_connections(sender), AppMsg::ConnectionListUnavailable => self.show_connection_list_banner(), AppMsg::SecretDeleteFailed(id) => self.on_secret_delete_failed(id, sender), diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index 31e1efe83d..f2dd00c605 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -16,7 +16,7 @@ use uuid::Uuid; use tablepro_core::ddl::TableDefinition; use tablepro_core::dialect::SqlDialect; -use tablepro_core::meta::{ObjectInfo, ObjectScope, TableRef}; +use tablepro_core::meta::{ObjectScope, TableRef}; use tablepro_core::sql_ddl::{BuildDdlError, DraftColumn, StructureOp, materialize_ops}; use tablepro_core::statement::{GuardedStatement, StatementBuilder}; use tablepro_core::{ForeignKeyMode, WriteBatch, WriteKind, WriteReport}; @@ -539,21 +539,22 @@ impl App { } } - /// Schema state changed somewhere — reload the table list, then - /// refetch any open Browse tab pointing at the affected table so - /// its grid reflects post-DDL schema (column adds / drops / type - /// changes / renames). + /// The schema changed under the window. The catalog forgets the + /// table's columns and reads the sidebar's list again, and every + /// Browse tab open on the table reads it again. pub(super) fn on_schema_changed( &self, schema: Option, table: Option, sender: ComponentSender, ) { - // Refetch the affected Browse tab(s) immediately. Tab-id - // collection happens under a short-lived borrow; the sender - // dispatches happen after drop so the input handlers can - // re-borrow workspace_tabs without panic. + let Some(session) = self.context.active.get() else { + return; + }; if let Some(table_name) = table.as_deref() { + session.invalidate_table(&TableRef::new(schema.clone(), table_name)); + // The ids are collected under a short borrow, so the inputs + // that follow can borrow the tabs again. let mut affected: Vec = Vec::new(); for (id, tab) in self.workspace_tabs.borrow().iter() { if let WorkspaceTab::Table(slot) = tab @@ -569,26 +570,18 @@ impl App { sender.input(AppMsg::FetchBrowseRowCount(id)); } } - // Sidebar refresh: re-list the objects and rebuild the factory. - let Some(session) = self.context.active.get() else { - return; - }; - let work = async move { - session - .run(OperationKind::Metadata, |lease, options| async move { - Ok(lease.list_objects(&ObjectScope::DefaultSchema, options).await?) - }) - .await - }; - crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| match settled(result) { - Ok(objects) => sender.input(AppMsg::TablesReloaded(objects)), - // The sidebar keeps the list it has rather than going empty. - Err(error) => tracing::warn!(%error, "could not reload the table list"), + // The new list reaches the sidebar through the catalog watch. + self.context.tasks.spawn_task(async move { + if let Err(error) = session.refresh_objects(ObjectScope::DefaultSchema).await { + tracing::warn!(%error, "could not reload the table list"); + } }); } - pub(super) fn on_tables_reloaded(&mut self, objects: Vec) { - self.repopulate_sidebar(&objects); + /// The sidebar and the editor's completion words follow the + /// session's object list. A failed refresh keeps both as they were. + pub(super) fn on_catalog_changed(&mut self, snapshot: &tablepro_session::ObjectListSnapshot) { + self.show_objects(&snapshot.objects); } pub(super) fn refresh_structure_tab_dirty(&self, tab_id: Uuid, dirty: bool) { diff --git a/linux/crates/session/src/health/health_monitor.rs b/linux/crates/session/src/health/health_monitor.rs index d2373e6e34..34541872a2 100644 --- a/linux/crates/session/src/health/health_monitor.rs +++ b/linux/crates/session/src/health/health_monitor.rs @@ -15,7 +15,7 @@ use crate::connect::{ConnectError, PendingSession}; use crate::link::{ConnectionLease, ConnectionLink}; use crate::operation::{DeadlinePolicy, OperationKind}; use crate::runtime::{Tasks, catch_panic}; -use crate::session::CatalogSnapshot; +use crate::session::SchemaCatalog; /// Everything the monitor task owns. It holds no `Session`, so dropping /// the last session handle is what ends it. @@ -27,7 +27,7 @@ pub(crate) struct MonitorContext { pub health: watch::Sender, pub mailbox: Arc, pub cancel: CancellationToken, - pub catalog: watch::Sender, + pub catalog: Arc, pub reachability: Arc>, pub close_timeout: Duration, } @@ -222,11 +222,7 @@ async fn reconnect(ctx: &MonitorContext, user_asked: bool) -> Option for OperationError { + fn from(failure: TaskFailure) -> Self { + match failure { + TaskFailure::Panicked(panic) => Self::Panicked(panic), + TaskFailure::Aborted => Self::Cancelled, + } + } +} diff --git a/linux/crates/session/src/session/catalog_snapshot.rs b/linux/crates/session/src/session/catalog_snapshot.rs deleted file mode 100644 index e35a2032e5..0000000000 --- a/linux/crates/session/src/session/catalog_snapshot.rs +++ /dev/null @@ -1,16 +0,0 @@ -use std::sync::Arc; - -use tablepro_core::meta::{ObjectInfo, SchemaInfo}; - -use crate::health::LinkGeneration; - -/// The catalog read when the current connection was established. -/// -/// Each reconnect reads it again and publishes it over the old one, so -/// the sidebar never goes empty while the new list is on its way. -#[derive(Debug, Clone)] -pub struct CatalogSnapshot { - pub generation: LinkGeneration, - pub schemas: Arc<[SchemaInfo]>, - pub objects: Arc<[ObjectInfo]>, -} diff --git a/linux/crates/session/src/session/mod.rs b/linux/crates/session/src/session/mod.rs index d635563460..c0d6522934 100644 --- a/linux/crates/session/src/session/mod.rs +++ b/linux/crates/session/src/session/mod.rs @@ -1,8 +1,9 @@ //! One window's session: its connection link, the operation chokepoint, -//! the health monitor, and an ordered shutdown. +//! the health monitor, the schema catalog, and an ordered shutdown. -mod catalog_snapshot; mod editor_session_shutdown; +mod object_list_snapshot; +mod schema_catalog; #[expect( clippy::module_inception, reason = "the Session type is the module's subject, as the plan names its file" @@ -10,7 +11,8 @@ mod editor_session_shutdown; mod session; mod session_deps; -pub use catalog_snapshot::CatalogSnapshot; pub use editor_session_shutdown::close_editor_sessions; +pub use object_list_snapshot::ObjectListSnapshot; +pub use schema_catalog::SchemaCatalog; pub use session::Session; pub use session_deps::SessionDeps; diff --git a/linux/crates/session/src/session/object_list_snapshot.rs b/linux/crates/session/src/session/object_list_snapshot.rs new file mode 100644 index 0000000000..908349de36 --- /dev/null +++ b/linux/crates/session/src/session/object_list_snapshot.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use tablepro_core::meta::{ObjectInfo, ObjectScope}; + +use crate::operation::OperationError; + +/// One scope's object list as the catalog last read it. +/// +/// A failed read keeps the list and records why, so a view goes on +/// showing what it has and can still say the refresh failed. +#[derive(Debug, Clone)] +pub struct ObjectListSnapshot { + pub scope: ObjectScope, + /// Bumps each time a new list lands, so a view can tell a new list + /// from a failed refresh of the one it already shows. + pub generation: u64, + pub objects: Arc<[ObjectInfo]>, + pub last_error: Option>, +} + +impl ObjectListSnapshot { + pub(crate) fn empty(scope: ObjectScope) -> Self { + Self { + scope, + generation: 0, + objects: Arc::from([]), + last_error: None, + } + } +} diff --git a/linux/crates/session/src/session/schema_catalog.rs b/linux/crates/session/src/session/schema_catalog.rs new file mode 100644 index 0000000000..4a43d8f271 --- /dev/null +++ b/linux/crates/session/src/session/schema_catalog.rs @@ -0,0 +1,704 @@ +use std::collections::HashMap; +use std::hash::Hash; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use futures::FutureExt; +use futures::future::{BoxFuture, Shared}; +use tablepro_core::meta::{ObjectInfo, ObjectScope, SchemaInfo}; +use tablepro_core::{CallOptions, ColumnInfo, DriverError, TableRef}; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; + +use super::ObjectListSnapshot; +use crate::link::ConnectionLease; +use crate::operation::{OperationError, OperationKind, OperationRunner}; +use crate::runtime::Tasks; + +type Fetched = Result, Arc>; +type SharedFetch = Shared>>; + +/// What the session knows about its database's schemas, objects and +/// columns, shared by every view of the window. +/// +/// Callers that ask for the same thing at once share one fetch, and a +/// fetch runs as its own task, so a view that goes away does not stop +/// the read another view is waiting on. A failed read keeps what was +/// there and records the error: nothing here ever empties a list a view +/// is showing. +#[derive(Clone)] +pub struct SchemaCatalog { + state: Arc>, + runner: OperationRunner, + tasks: Tasks, +} + +#[derive(Default)] +struct CatalogState { + next_fetch: u64, + schemas: HashMap<(), Entry>, + objects: HashMap>, + columns: HashMap>, + watchers: HashMap>, +} + +struct Entry { + data: Option>, + /// Known out of date: the next read fetches again, and the data + /// stays until that fetch lands. + stale: bool, + last_error: Option>, + generation: u64, + /// Bumped by a commit or an invalidation. A fetch that began under + /// an older epoch answers its own callers but does not land. + epoch: u64, + inflight: Option>, +} + +impl Default for Entry { + fn default() -> Self { + Self { + data: None, + stale: false, + last_error: None, + generation: 0, + epoch: 0, + inflight: None, + } + } +} + +struct Inflight { + id: u64, + epoch: u64, + fetch: SharedFetch, + /// Set once the fetch has asked the server. A refresh that arrives + /// before then joins it; one after waits for it and asks again. + started: Arc, +} + +/// One kind of thing the catalog holds, and how to read it. +trait CatalogItem: Clone + Send + Sync + Sized + 'static { + type Key: Clone + Eq + Hash + Send + Sync + 'static; + + fn entries(state: &mut CatalogState) -> &mut HashMap>; + + fn fetch( + lease: ConnectionLease, + key: Self::Key, + options: CallOptions, + ) -> BoxFuture<'static, Result, DriverError>>; + + fn changed(_state: &CatalogState, _key: &Self::Key) {} +} + +impl CatalogItem for SchemaInfo { + type Key = (); + + fn entries(state: &mut CatalogState) -> &mut HashMap<(), Entry> { + &mut state.schemas + } + + fn fetch( + lease: ConnectionLease, + _key: (), + options: CallOptions, + ) -> BoxFuture<'static, Result, DriverError>> { + Box::pin(async move { lease.list_schemas(options).await }) + } +} + +impl CatalogItem for ObjectInfo { + type Key = ObjectScope; + + fn entries(state: &mut CatalogState) -> &mut HashMap> { + &mut state.objects + } + + fn fetch( + lease: ConnectionLease, + scope: ObjectScope, + options: CallOptions, + ) -> BoxFuture<'static, Result, DriverError>> { + Box::pin(async move { lease.list_objects(&scope, options).await }) + } + + fn changed(state: &CatalogState, scope: &ObjectScope) { + if let Some(watcher) = state.watchers.get(scope) { + watcher.send_replace(state.snapshot(scope)); + } + } +} + +impl CatalogItem for ColumnInfo { + type Key = TableRef; + + fn entries(state: &mut CatalogState) -> &mut HashMap> { + &mut state.columns + } + + fn fetch( + lease: ConnectionLease, + table: TableRef, + options: CallOptions, + ) -> BoxFuture<'static, Result, DriverError>> { + Box::pin(async move { lease.fetch_columns(&table, options).await }) + } +} + +impl CatalogState { + fn snapshot(&self, scope: &ObjectScope) -> ObjectListSnapshot { + let Some(entry) = self.objects.get(scope) else { + return ObjectListSnapshot::empty(scope.clone()); + }; + ObjectListSnapshot { + scope: scope.clone(), + generation: entry.generation, + objects: entry.data.clone().unwrap_or_else(|| Arc::from([])), + last_error: entry.last_error.clone(), + } + } +} + +impl SchemaCatalog { + /// A catalog that starts with what the connect already read: the + /// schemas and the default schema's objects. + pub fn new(schemas: Vec, objects: Vec, runner: OperationRunner, tasks: Tasks) -> Self { + let catalog = Self { + state: Arc::new(Mutex::new(CatalogState::default())), + runner, + tasks, + }; + catalog.commit(schemas, objects); + catalog + } + + pub async fn schemas(&self) -> Fetched { + self.read::(()).await + } + + pub async fn objects(&self, scope: ObjectScope) -> Fetched { + self.read::(scope).await + } + + /// Read the scope's objects again, even when a list is cached. + pub async fn refresh_objects(&self, scope: ObjectScope) -> Fetched { + self.refresh::(scope).await + } + + pub async fn columns(&self, table: TableRef) -> Fetched { + self.read::(table).await + } + + /// Read the table's columns again. A failure leaves the cached list + /// in place for the next `columns`. + pub async fn refresh_columns(&self, table: TableRef) -> Fetched { + self.refresh::(table).await + } + + /// The columns already read for a table, without asking the server. + pub fn cached_columns(&self, table: &TableRef) -> Option> { + self.lock().columns.get(table).and_then(|entry| entry.data.clone()) + } + + /// Put in place what a new connection read, over whatever was there. + /// + /// Everything else becomes stale, because the catalog cannot know + /// what changed while the connection was away. A fetch that started + /// before this no longer lands. + pub fn commit(&self, schemas: Vec, objects: Vec) { + let mut state = self.lock(); + mark_all_stale(&mut state.schemas); + mark_all_stale(&mut state.objects); + mark_all_stale(&mut state.columns); + land(state.schemas.entry(()).or_default(), Arc::from(schemas)); + land( + state.objects.entry(ObjectScope::DefaultSchema).or_default(), + Arc::from(objects), + ); + ObjectInfo::changed(&state, &ObjectScope::DefaultSchema); + } + + /// The table changed: its columns are read again on the next ask, + /// and until then the old ones stay for whoever shows them. + pub fn invalidate_table(&self, table: &TableRef) { + if let Some(entry) = self.lock().columns.get_mut(table) { + entry.stale = true; + entry.epoch += 1; + } + } + + /// The scope's object list now and after every change. Subscribing + /// does not read anything; `objects` or `refresh_objects` does. + pub fn subscribe(&self, scope: ObjectScope) -> watch::Receiver { + let mut state = self.lock(); + if let Some(watcher) = state.watchers.get(&scope) { + return watcher.subscribe(); + } + let (watcher, receiver) = watch::channel(state.snapshot(&scope)); + state.watchers.insert(scope, watcher); + receiver + } + + fn lock(&self) -> MutexGuard<'_, CatalogState> { + lock(&self.state) + } + + fn read(&self, key: T::Key) -> BoxFuture<'static, Fetched> { + let mut state = self.lock(); + let entry = T::entries(&mut state).entry(key.clone()).or_default(); + if let (Some(data), false) = (&entry.data, entry.stale) { + return futures::future::ready(Ok(data.clone())).boxed(); + } + let after = match &entry.inflight { + Some(inflight) if inflight.epoch == entry.epoch => return inflight.fetch.clone().boxed(), + Some(inflight) => Some(inflight.fetch.clone()), + None => None, + }; + self.start::(&mut state, key, after).boxed() + } + + fn refresh(&self, key: T::Key) -> BoxFuture<'static, Fetched> { + let mut state = self.lock(); + let entry = T::entries(&mut state).entry(key.clone()).or_default(); + let after = match &entry.inflight { + Some(inflight) if inflight.epoch == entry.epoch && !inflight.started.load(Ordering::SeqCst) => { + return inflight.fetch.clone().boxed(); + } + Some(inflight) => Some(inflight.fetch.clone()), + None => None, + }; + self.start::(&mut state, key, after).boxed() + } + + /// Spawn one fetch, after `after` when one is already asking, so a + /// key never has two reads on the server at once. + fn start( + &self, + state: &mut CatalogState, + key: T::Key, + after: Option>, + ) -> SharedFetch { + let id = state.next_fetch; + state.next_fetch += 1; + let entry = T::entries(state).entry(key.clone()).or_default(); + let epoch = entry.epoch; + let started = Arc::new(AtomicBool::new(false)); + let runner = self.runner.clone(); + let catalog = self.state.clone(); + let started_flag = started.clone(); + let work = async move { + if let Some(after) = after { + let _ = after.await; + } + started_flag.store(true, Ordering::SeqCst); + let fetch_key = key.clone(); + // The catalog's reads belong to no one view, so only the + // session shutting down stops them. + let result = runner + .run_cancellable( + OperationKind::Metadata, + &CancellationToken::new(), + move |lease, options| { + let key = fetch_key.clone(); + async move { Ok(T::fetch(lease, key, options).await?) } + }, + ) + .await + .map(Arc::from) + .map_err(Arc::new); + settle::(&catalog, &key, id, epoch, &result); + result + }; + let running = self.tasks.spawn_task(work); + let fetch = async move { + running + .await + .unwrap_or_else(|failure| Err(Arc::new(OperationError::from(failure)))) + } + .boxed() + .shared(); + entry.inflight = Some(Inflight { + id, + epoch, + fetch: fetch.clone(), + started, + }); + fetch + } +} + +impl std::fmt::Debug for SchemaCatalog { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let state = self.lock(); + f.debug_struct("SchemaCatalog") + .field("scopes", &state.objects.len()) + .field("tables", &state.columns.len()) + .finish_non_exhaustive() + } +} + +fn lock(state: &Mutex) -> MutexGuard<'_, CatalogState> { + state.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn mark_all_stale(entries: &mut HashMap>) { + for entry in entries.values_mut() { + entry.stale = true; + entry.epoch += 1; + } +} + +fn land(entry: &mut Entry, data: Arc<[T]>) { + entry.data = Some(data); + entry.stale = false; + entry.last_error = None; + entry.generation += 1; +} + +/// Record what a fetch read. A failure keeps the data and only says +/// why; a fetch overtaken by a commit or an invalidation records +/// nothing. +fn settle(state: &Mutex, key: &T::Key, id: u64, epoch: u64, result: &Fetched) { + let mut state = lock(state); + let entry = T::entries(&mut state).entry(key.clone()).or_default(); + if entry.inflight.as_ref().is_some_and(|inflight| inflight.id == id) { + entry.inflight = None; + } + if entry.epoch != epoch { + return; + } + match result { + Ok(data) => land(entry, data.clone()), + Err(error) => entry.last_error = Some(error.clone()), + } + T::changed(&state, key); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::LivenessPolicy; + use tablepro_core::meta::ObjectKind; + use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeReply}; + + use super::*; + use crate::health::{MonitorMailbox, SessionHealth}; + use crate::link::{ConnectionLink, EstablishedConnection}; + use crate::operation::DefaultDeadlines; + use crate::test_support::paused_tasks; + + const SLOW: Duration = Duration::from_secs(1); + + fn object(name: &str) -> ObjectInfo { + ObjectInfo { + schema: Some("public".to_owned()), + name: name.to_owned(), + kind: ObjectKind::Table, + } + } + + fn column(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: tablepro_core::column::ColumnType::untyped_text("text"), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: tablepro_core::column::ColumnDefault::None, + comment: None, + } + } + + fn names(items: &[T], name: impl Fn(&T) -> &str) -> Vec { + items.iter().map(|item| name(item).to_owned()).collect() + } + + struct Harness { + catalog: SchemaCatalog, + connection: Arc, + _health: watch::Sender, + session: CancellationToken, + } + + fn harness(script: ConnectionScript) -> Harness { + let connection = Arc::new(FakeConnection::new(script)); + let link = Arc::new(ConnectionLink::new(EstablishedConnection { + connection: connection.clone(), + ssh_session: None, + })); + let (health, health_receiver) = watch::channel(SessionHealth::Online); + let session = CancellationToken::new(); + let runner = OperationRunner::new( + link, + Arc::new(DefaultDeadlines::new(LivenessPolicy::DESKTOP)), + Arc::new(MonitorMailbox::new()), + health_receiver, + session.clone(), + ); + let catalog = SchemaCatalog::new(Vec::new(), vec![object("first")], runner, paused_tasks()); + Harness { + catalog, + connection, + _health: health, + session, + } + } + + fn sales() -> ObjectScope { + ObjectScope::Schema("sales".to_owned()) + } + + #[tokio::test(start_paused = true)] + async fn ten_concurrent_objects_calls_one_list_objects() { + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::Bounded { + after: SLOW, + outcome: Ok(vec![object("orders")]), + }); + let harness = harness(script); + + let lists = futures::future::join_all((0..10).map(|_| harness.catalog.objects(sales()))).await; + + assert_eq!(CallCounters::get(&harness.connection.counters().list_objects), 1); + for list in lists { + assert_eq!(names(&list.unwrap(), |o| &o.name), ["orders"]); + } + let cached = harness.catalog.objects(sales()).await.unwrap(); + assert_eq!(names(&cached, |o| &o.name), ["orders"]); + assert_eq!(CallCounters::get(&harness.connection.counters().list_objects), 1); + } + + #[tokio::test(start_paused = true)] + async fn refresh_during_fetch_reruns_once() { + let mut script = ConnectionScript::default(); + script + .list_objects + .once(FakeReply::Bounded { + after: SLOW, + outcome: Ok(vec![object("before")]), + }) + .once(FakeReply::Bounded { + after: SLOW, + outcome: Ok(vec![object("after")]), + }); + let harness = harness(script); + let first = paused_tasks().spawn_task(harness.catalog.read::(sales())); + tokio::time::sleep(Duration::from_millis(10)).await; + + let (one, two) = tokio::join!( + harness.catalog.refresh_objects(sales()), + harness.catalog.refresh_objects(sales()) + ); + + assert_eq!(names(&first.await.unwrap().unwrap(), |o| &o.name), ["before"]); + assert_eq!(names(&one.unwrap(), |o| &o.name), ["after"]); + assert_eq!(names(&two.unwrap(), |o| &o.name), ["after"]); + assert_eq!( + CallCounters::get(&harness.connection.counters().list_objects), + 2, + "two refreshes during one fetch ran more than one more read" + ); + assert_eq!(harness.catalog.subscribe(sales()).borrow().generation, 2); + } + + #[tokio::test(start_paused = true)] + async fn failed_refresh_keeps_objects_sets_last_error() { + let mut script = ConnectionScript::default(); + script + .list_objects + .always(FakeReply::err(DriverError::server("denied"))); + let harness = harness(script); + let watcher = harness.catalog.subscribe(ObjectScope::DefaultSchema); + + let refreshed = harness.catalog.refresh_objects(ObjectScope::DefaultSchema).await; + + assert!(refreshed.is_err()); + let snapshot = watcher.borrow().clone(); + assert_eq!(names(&snapshot.objects, |o| &o.name), ["first"]); + assert_eq!(snapshot.generation, 1, "a failed read counted as a new list"); + assert!(snapshot.last_error.is_some()); + let cached = harness.catalog.objects(ObjectScope::DefaultSchema).await.unwrap(); + assert_eq!(names(&cached, |o| &o.name), ["first"]); + } + + #[tokio::test(start_paused = true)] + async fn failed_column_refresh_returns_the_error_and_keeps_the_list() { + let mut script = ConnectionScript::default(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id")])) + .always(FakeReply::err(DriverError::server("denied"))); + let harness = harness(script); + let table = TableRef::unqualified("users"); + harness.catalog.columns(table.clone()).await.unwrap(); + + let refreshed = harness.catalog.refresh_columns(table.clone()).await; + + assert!(refreshed.is_err()); + let cached = harness.catalog.columns(table).await.unwrap(); + assert_eq!(names(&cached, |c| &c.name), ["id"]); + assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 2); + } + + #[tokio::test(start_paused = true)] + async fn columns_coalesce_per_table() { + let mut script = ConnectionScript::default(); + script + .fetch_columns + .when_sql_contains( + "users", + FakeReply::Bounded { + after: SLOW, + outcome: Ok(vec![column("id")]), + }, + ) + .when_sql_contains( + "orders", + FakeReply::Bounded { + after: SLOW, + outcome: Ok(vec![column("total")]), + }, + ); + let harness = harness(script); + let users = TableRef::unqualified("users"); + let orders = TableRef::unqualified("orders"); + + let (a, b, c) = tokio::join!( + harness.catalog.columns(users.clone()), + harness.catalog.columns(users), + harness.catalog.columns(orders) + ); + + assert_eq!(names(&a.unwrap(), |c| &c.name), ["id"]); + assert_eq!(names(&b.unwrap(), |c| &c.name), ["id"]); + assert_eq!(names(&c.unwrap(), |c| &c.name), ["total"]); + assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 2); + } + + #[tokio::test(start_paused = true)] + async fn invalidate_keeps_data_until_refetch() { + let mut script = ConnectionScript::default(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id")])) + .once(FakeReply::Bounded { + after: SLOW, + outcome: Ok(vec![column("id"), column("email")]), + }); + let harness = harness(script); + let table = TableRef::unqualified("users"); + harness.catalog.columns(table.clone()).await.unwrap(); + + harness.catalog.invalidate_table(&table); + let refetch = paused_tasks().spawn_task(harness.catalog.read::(table.clone())); + tokio::time::sleep(SLOW / 2).await; + let during = harness.catalog.cached_columns(&table).unwrap(); + let after = refetch.await.unwrap().unwrap(); + + assert_eq!( + names(&during, |c| &c.name), + ["id"], + "the refetch emptied the list early" + ); + assert_eq!(names(&after, |c| &c.name), ["id", "email"]); + assert_eq!( + names(&harness.catalog.cached_columns(&table).unwrap(), |c| &c.name), + ["id", "email"] + ); + assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 2); + } + + #[tokio::test(start_paused = true)] + async fn a_fetch_started_before_an_invalidation_does_not_land() { + let mut script = ConnectionScript::default(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id")])) + .once(FakeReply::Bounded { + after: SLOW, + outcome: Ok(vec![column("old")]), + }) + .once(FakeReply::ok(vec![column("new")])); + let harness = harness(script); + let table = TableRef::unqualified("users"); + harness.catalog.columns(table.clone()).await.unwrap(); + let early = paused_tasks().spawn_task(harness.catalog.refresh::(table.clone())); + tokio::time::sleep(Duration::from_millis(10)).await; + + harness.catalog.invalidate_table(&table); + let late = harness.catalog.columns(table.clone()).await.unwrap(); + + assert_eq!(names(&early.await.unwrap().unwrap(), |c| &c.name), ["old"]); + assert_eq!(names(&late, |c| &c.name), ["new"]); + assert_eq!( + names(&harness.catalog.cached_columns(&table).unwrap(), |c| &c.name), + ["new"] + ); + } + + #[tokio::test(start_paused = true)] + async fn dropped_awaiter_does_not_cancel_fetch() { + let mut script = ConnectionScript::default(); + script.fetch_columns.always(FakeReply::Bounded { + after: SLOW, + outcome: Ok(vec![column("id")]), + }); + let harness = harness(script); + let table = TableRef::unqualified("users"); + + let waiting = paused_tasks().spawn_task(harness.catalog.read::(table.clone())); + tokio::time::sleep(Duration::from_millis(10)).await; + waiting.abort(); + tokio::time::sleep(SLOW * 2).await; + + assert_eq!( + names(&harness.catalog.cached_columns(&table).unwrap(), |c| &c.name), + ["id"], + "the fetch stopped with its caller" + ); + harness.catalog.columns(table).await.unwrap(); + assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 1); + assert_eq!(CallCounters::get(&harness.connection.counters().stopped), 0); + } + + #[tokio::test(start_paused = true)] + async fn commit_replaces_the_default_list_and_marks_the_rest_stale() { + let mut script = ConnectionScript::default(); + script.fetch_columns.always(FakeReply::ok(vec![column("id")])); + let harness = harness(script); + let table = TableRef::unqualified("users"); + harness.catalog.columns(table.clone()).await.unwrap(); + let watcher = harness.catalog.subscribe(ObjectScope::DefaultSchema); + + harness.catalog.commit(Vec::new(), vec![object("second")]); + harness.catalog.columns(table.clone()).await.unwrap(); + + let snapshot = watcher.borrow().clone(); + assert_eq!(names(&snapshot.objects, |o| &o.name), ["second"]); + assert_eq!(snapshot.generation, 2); + assert_eq!( + CallCounters::get(&harness.connection.counters().fetch_columns), + 2, + "a column list from before the reconnect was trusted" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_closed_session_fails_the_read_and_keeps_the_list() { + let mut script = ConnectionScript::default(); + script + .list_objects + .always(FakeReply::err(DriverError::server("unreachable"))); + let harness = harness(script); + harness.session.cancel(); + + let refreshed = harness.catalog.refresh_objects(ObjectScope::DefaultSchema).await; + + assert_eq!(refreshed.map_err(|error| (*error).clone()), Err(OperationError::Closed)); + let cached = harness.catalog.objects(ObjectScope::DefaultSchema).await.unwrap(); + assert_eq!(names(&cached, |o| &o.name), ["first"]); + } +} diff --git a/linux/crates/session/src/session/session.rs b/linux/crates/session/src/session/session.rs index 2f3cb4ae3c..cb363216c9 100644 --- a/linux/crates/session/src/session/session.rs +++ b/linux/crates/session/src/session/session.rs @@ -3,13 +3,14 @@ use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use tablepro_core::dialect::SqlDialect; -use tablepro_core::{CallOptions, EditorSession, EditorSessionOptions, WorkspaceSettings}; +use tablepro_core::meta::{ObjectInfo, ObjectScope, SchemaInfo}; +use tablepro_core::{CallOptions, ColumnInfo, EditorSession, EditorSessionOptions, TableRef, WorkspaceSettings}; use tokio::sync::watch; use tokio_util::sync::{CancellationToken, DropGuard}; use tokio_util::task::AbortOnDropHandle; use uuid::Uuid; -use super::{CatalogSnapshot, SessionDeps}; +use super::{ObjectListSnapshot, SchemaCatalog, SessionDeps}; use crate::connect::{ConnectionMetadata, PendingSession}; use crate::health::{ LinkGeneration, MonitorContext, MonitorMailbox, MonitorSignal, ReachabilityTarget, ReconnectPlan, SessionHealth, @@ -33,7 +34,7 @@ pub struct Session { mailbox: Arc, health: watch::Receiver, link: Arc, - catalog: watch::Receiver, + catalog: Arc, tasks: Tasks, close_timeout: Duration, cancel: CancellationToken, @@ -55,11 +56,6 @@ impl Session { let link = Arc::new(ConnectionLink::new(parts.established)); let mailbox = Arc::new(MonitorMailbox::new()); let (health_sender, health) = watch::channel(SessionHealth::Online); - let (catalog_sender, catalog) = watch::channel(CatalogSnapshot { - generation: link.generation(), - schemas: parts.schemas.into(), - objects: parts.objects.into(), - }); let cancel = CancellationToken::new(); let runner = OperationRunner::new( link.clone(), @@ -68,6 +64,12 @@ impl Session { health.clone(), cancel.clone(), ); + let catalog = Arc::new(SchemaCatalog::new( + parts.schemas, + parts.objects, + runner.clone(), + deps.tasks.clone(), + )); let context = MonitorContext { link: link.clone(), plan: ReconnectPlan { @@ -81,7 +83,7 @@ impl Session { health: health_sender, mailbox: mailbox.clone(), cancel: cancel.clone(), - catalog: catalog_sender, + catalog: catalog.clone(), reachability: reachability.clone(), close_timeout, }; @@ -137,6 +139,12 @@ impl Session { self.link.lease().map(|lease| lease.dialect()) } + /// Which connection is current. A request issued under an older one + /// may have failed because that connection went away. + pub fn link_generation(&self) -> LinkGeneration { + self.link.generation() + } + /// Ticks each time a reconnect replaces the connection. pub fn link_changes(&self) -> watch::Receiver { self.link.subscribe() @@ -146,9 +154,38 @@ impl Session { self.health.clone() } - /// The catalog of the current connection, as read when it connected. - pub fn catalog(&self) -> watch::Receiver { - self.catalog.clone() + /// What the window knows about the database's schemas, objects and + /// columns, shared by every view. + pub fn catalog(&self) -> &SchemaCatalog { + &self.catalog + } + + pub async fn schemas(&self) -> Result, Arc> { + self.catalog.schemas().await + } + + pub async fn objects(&self, scope: ObjectScope) -> Result, Arc> { + self.catalog.objects(scope).await + } + + pub async fn refresh_objects(&self, scope: ObjectScope) -> Result, Arc> { + self.catalog.refresh_objects(scope).await + } + + pub async fn columns(&self, table: TableRef) -> Result, Arc> { + self.catalog.columns(table).await + } + + pub async fn refresh_columns(&self, table: TableRef) -> Result, Arc> { + self.catalog.refresh_columns(table).await + } + + pub fn invalidate_table(&self, table: &TableRef) { + self.catalog.invalidate_table(table); + } + + pub fn subscribe_objects(&self, scope: ObjectScope) -> watch::Receiver { + self.catalog.subscribe(scope) } pub fn signal(&self, signal: MonitorSignal) { @@ -358,7 +395,7 @@ mod tests { assert!(session.read_only()); assert!(session.current_dialect().is_some()); assert_eq!(session.reachability(), ReachabilityTarget::Local); - assert_eq!(session.catalog().borrow().generation, LinkGeneration::FIRST); + assert_eq!(*session.link_changes().borrow(), LinkGeneration::FIRST); assert_eq!(session.metadata().name, "Fake"); } diff --git a/linux/crates/session/tests/session_postgres.rs b/linux/crates/session/tests/session_postgres.rs index bf7adfb2c2..fa4a33d65b 100644 --- a/linux/crates/session/tests/session_postgres.rs +++ b/linux/crates/session/tests/session_postgres.rs @@ -280,6 +280,70 @@ async fn saturated_pool_operation_busy_probe_ok_stays_online() { ); } +/// How many backends other than the admin's own are running a +/// `pg_sleep(30)` right now. +async fn sleeping_backends(admin: &Arc) -> Option { + let result = admin + .query_bound( + &statement( + "SELECT count(*) FROM pg_stat_activity \ + WHERE state = 'active' AND query LIKE '%pg_sleep(30)%' AND pid <> pg_backend_pid()", + ), + RowLimit::EDITOR_DEFAULT, + within(WAIT), + ) + .await + .expect("the activity count"); + match result.rows.first().and_then(|row| row.first()) { + Some(Value::Int(count)) => Some(*count), + _ => None, + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn run_cancellable_stops_server_statement() { + let fixture = PostgresFixture::start(HbaMode::Password).await.expect("PostgreSQL"); + let connector = connector(isolated_ssh().services); + let session = start( + connector.clone(), + establish(&connector, request(direct_target(&fixture))).await, + ); + let admin = admin(&fixture).await; + let cancel = CancellationToken::new(); + let running = { + let session = session.clone(); + let cancel = cancel.clone(); + tasks().spawn_task(async move { + session + .run_cancellable(OperationKind::Page, &cancel, |lease, options| async move { + let sleep = statement("SELECT pg_sleep(30)::text"); + Ok(lease.query_bound(&sleep, RowLimit::EDITOR_DEFAULT, options).await?) + }) + .await + }) + }; + let started = tokio::time::Instant::now(); + while sleeping_backends(&admin).await.expect("an integer count") == 0 { + assert!(started.elapsed() < WAIT, "the statement never started"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + cancel.cancel(); + let stopped = tokio::time::Instant::now(); + let outcome = running.await.expect("the operation task"); + while sleeping_backends(&admin).await.expect("an integer count") > 0 { + assert!( + stopped.elapsed() < Duration::from_secs(2), + "the server kept running the statement after its caller let go" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + assert_eq!(outcome.map(|_| ()), Err(OperationError::Cancelled)); + assert!(stopped.elapsed() < Duration::from_secs(2)); +} + fn ssh_target(sshd: &OpenSshFixture, postgres: &PostgresFixture, key: &Path) -> RequestTarget { let postgres_host = postgres.network_alias().expect("PostgreSQL on the test network"); RequestTarget::Network { diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index e9ab4aa0f9..8ba9adadad 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -42,6 +42,7 @@ crates/app/src/services/workspace_state.rs crates/app/src/storage.rs crates/app/src/test_support.rs crates/app/src/test_support/accessible_label.rs +crates/app/src/test_support/fake_session.rs crates/app/src/test_support/main_loop.rs crates/app/src/test_support/memory_settings.rs crates/app/src/test_support/signal_log.rs From bd2e137442ab24448195365acc36f45312cd2842 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 19:27:27 +0700 Subject: [PATCH 088/178] refactor(linux): let browse tabs own sequenced, cancellable fetches BrowseTab is a gtk-rs widget with a composite template now. It reads its columns from the session catalog and then its page and count, one request of each kind at a time, and reads again what failed once the session reconnects. --- .../app/src/test_support/fake_session.rs | 15 +- linux/crates/app/src/ui/app/browse.rs | 277 -- linux/crates/app/src/ui/app/mod.rs | 53 +- linux/crates/app/src/ui/app/row_ops.rs | 18 +- linux/crates/app/src/ui/app/status_pages.rs | 8 - linux/crates/app/src/ui/app/structure.rs | 42 +- linux/crates/app/src/ui/app/workspace_tabs.rs | 186 +- linux/crates/app/src/ui/browse_tab.rs | 2958 ----------------- .../app/src/ui/browse_tab/browse_command.rs | 25 + .../src/ui/browse_tab/browse_request_kind.rs | 10 + linux/crates/app/src/ui/browse_tab/fetch.rs | 314 ++ linux/crates/app/src/ui/browse_tab/imp.rs | 209 ++ linux/crates/app/src/ui/browse_tab/mod.rs | 1973 +++++++++++ .../app/src/ui/browse_tab/page_reply.rs | 20 + linux/crates/app/src/ui/browse_tab/tests.rs | 554 +++ linux/data/resources/resources.gresource.xml | 1 + linux/data/resources/ui/browse-tab.ui | 227 ++ linux/po/POTFILES.in | 10 +- 18 files changed, 3514 insertions(+), 3386 deletions(-) delete mode 100644 linux/crates/app/src/ui/app/browse.rs delete mode 100644 linux/crates/app/src/ui/browse_tab.rs create mode 100644 linux/crates/app/src/ui/browse_tab/browse_command.rs create mode 100644 linux/crates/app/src/ui/browse_tab/browse_request_kind.rs create mode 100644 linux/crates/app/src/ui/browse_tab/fetch.rs create mode 100644 linux/crates/app/src/ui/browse_tab/imp.rs create mode 100644 linux/crates/app/src/ui/browse_tab/mod.rs create mode 100644 linux/crates/app/src/ui/browse_tab/page_reply.rs create mode 100644 linux/crates/app/src/ui/browse_tab/tests.rs create mode 100644 linux/data/resources/ui/browse-tab.ui diff --git a/linux/crates/app/src/test_support/fake_session.rs b/linux/crates/app/src/test_support/fake_session.rs index 451c285f3e..b24f7330cb 100644 --- a/linux/crates/app/src/test_support/fake_session.rs +++ b/linux/crates/app/src/test_support/fake_session.rs @@ -11,6 +11,8 @@ use tablepro_test_support::{ConnectionScript, FakeConnection}; pub(crate) struct FakeSession { pub session: Arc, pub connection: Arc, + /// Every connection a reconnect built, in order. + pub connector: FakeConnector, } impl FakeSession { @@ -25,13 +27,22 @@ impl FakeSession { let session = Session::start( pending, SessionDeps { - connector: Arc::new(connector), + connector: Arc::new(connector.clone()), credentials: Arc::new(FakeCredentialSource::new()), deadlines: Arc::new(DefaultDeadlines::new(LivenessPolicy::DESKTOP)), tasks, prompter: None, }, ); - Self { session, connection } + Self { + session, + connection, + connector, + } + } + + /// The connection the last reconnect put in place. + pub(crate) fn replacement(&self) -> Arc { + self.connector.connections().pop().expect("a reconnect's connection") } } diff --git a/linux/crates/app/src/ui/app/browse.rs b/linux/crates/app/src/ui/app/browse.rs deleted file mode 100644 index 0ba5bf1c1e..0000000000 --- a/linux/crates/app/src/ui/app/browse.rs +++ /dev/null @@ -1,277 +0,0 @@ -use relm4::adw::prelude::*; -use relm4::{ComponentController, ComponentSender, adw}; - -use tablepro_core::browse::{PageRequest, SortKey, build_count, build_page, split_page}; -use tablepro_core::meta::{PagePosition, TableRef}; -use tablepro_core::statement::StatementBuilder; -use tablepro_core::{CallOptions, ColumnInfo, DriverError, FilterSet, QueryResult, RowLimit}; -use tablepro_session::{ConnectionLease, OperationKind, Session}; -use uuid::Uuid; - -use crate::ui::browse_tab::BrowseTabInput; - -use super::{App, AppMsg, OpenMode}; - -impl App { - /// Sidebar click — routes via OpenMode (smart switch / new tab). - pub(super) fn on_select_table( - &mut self, - schema: Option, - name: String, - open_mode: OpenMode, - sender: ComponentSender, - ) { - self.dispatch_select_table(schema, name, open_mode, sender); - } - - /// Fetch the current page of a browse tab, under its sort, filter - /// and position. The SQL is built inside the operation from the - /// connection it runs on, so it matches that server's dialect even - /// when a reconnect lands in between. - pub(super) fn fetch_browse_page(&self, tab_id: Uuid, sender: ComponentSender) { - let Some(request) = self.browse_request(tab_id) else { - return; - }; - let Some(session) = self.context.active.get() else { - sender.input(AppMsg::LoadFailed( - Some(tab_id), - crate::i18n::gettext("No active connection"), - )); - return; - }; - // A filter the dialect cannot write is the user's to fix, so it - // is a toast now rather than a failed page later. - if let Some(message) = filter_problem(&session, &request) { - sender.input(AppMsg::ShowToast(message)); - return; - } - let offset = request.offset; - let work = async move { - session - .run(OperationKind::Page, move |lease, options| { - let request = request.clone(); - async move { - let columns = request.columns(&lease, &options).await?; - let identity = lease.row_identity(&request.table, options.clone()).await?; - let position = match request.offset { - 0 => PagePosition::First, - offset => PagePosition::Offset(offset), - }; - let spec = build_page( - &*lease.dialect(), - &PageRequest { - table: &request.table, - columns: &columns, - filter: &request.filter, - sort: &request.sort, - identity: &identity, - position: &position, - limit: request.limit, - }, - )?; - let limit = RowLimit::new(request.limit).unwrap_or(RowLimit::EDITOR_DEFAULT); - let result = lease.query_bound(&spec.statement, limit, options).await?; - let page = split_page(result, &spec)?; - Ok(QueryResult::new(page.columns, page.rows).truncated(page.truncated)) - } - }) - .await - }; - self.context.tasks.spawn_task(async move { - match work.await { - Ok(result) => sender.input(AppMsg::RowsLoaded(tab_id, offset, result)), - Err(error) => sender.input(AppMsg::LoadFailed( - Some(tab_id), - crate::ui::error_text::operation_error_message(&error), - )), - } - }); - } - - pub(super) fn fetch_browse_columns(&self, tab_id: Uuid, sender: ComponentSender) { - let Some(table) = self.browse_request(tab_id).map(|request| request.table) else { - return; - }; - let Some(session) = self.context.active.get() else { - return; - }; - self.context.tasks.spawn_task(async move { - let columns = session - .run(OperationKind::Metadata, move |lease, options| { - let table = table.clone(); - async move { Ok(lease.fetch_columns(&table, options).await?) } - }) - .await; - match columns { - Ok(columns) => sender.input(AppMsg::ColumnsLoaded(tab_id, columns)), - Err(error) => tracing::warn!(%error, "could not read the table's columns"), - } - }); - } - - /// The "of N" total, under the same filter as the page so the two - /// agree. - pub(super) fn fetch_browse_row_count(&self, tab_id: Uuid, sender: ComponentSender) { - let Some(request) = self.browse_request(tab_id) else { - return; - }; - let Some(session) = self.context.active.get() else { - return; - }; - // The page fetch toasts a bad filter on the same tick, so the - // count stays quiet about it. - if filter_problem(&session, &request).is_some() { - return; - } - self.context.tasks.spawn_task(async move { - let counted = session - .run(OperationKind::Count, move |lease, options| { - let request = request.clone(); - async move { - let columns = request.columns(&lease, &options).await?; - let statement = build_count(&*lease.dialect(), &request.table, &columns, &request.filter)?; - Ok(lease.count_rows(&statement, options).await?) - } - }) - .await; - match counted { - Ok(count) => sender.input(AppMsg::RowCountLoaded(tab_id, count)), - Err(error) => tracing::debug!(%error, "the row count could not be read"), - } - }); - } - - /// What a page or count of this tab depends on, read on the GTK - /// thread so the work that follows owns its copy. - fn browse_request(&self, tab_id: Uuid) -> Option { - let tabs = self.workspace_tabs.borrow(); - let model = tabs.get(&tab_id)?.browse_controller()?.model(); - Some(BrowseRequest { - table: TableRef::new(model.schema().map(str::to_owned), model.table()), - columns: model.columns().to_vec(), - filter: model.current_filter().clone(), - sort: model - .current_sort() - .map(|(column, ascending)| SortKey { column, ascending }) - .into_iter() - .collect(), - offset: model.current_offset(), - limit: model.page_size(), - }) - } - - pub(super) fn on_browse_columns_loaded(&self, tab_id: Uuid, columns: Vec) { - self.dispatch_to_tab(tab_id, BrowseTabInput::ColumnsLoaded(columns)); - } - - pub(super) fn on_browse_rows_loaded(&self, tab_id: Uuid, offset: u64, result: QueryResult) { - self.dispatch_to_tab(tab_id, BrowseTabInput::RowsLoaded { offset, result }); - } - - pub(super) fn on_browse_row_count_loaded(&self, tab_id: Uuid, count: u64) { - self.dispatch_to_tab(tab_id, BrowseTabInput::RowCountLoaded(count)); - } - - pub(super) fn on_browse_load_failed(&mut self, tab_id: Option, msg: String) { - match tab_id { - Some(id) => self.dispatch_to_tab(id, BrowseTabInput::ShowError(msg)), - None => { - tracing::warn!(error = %msg, "app-level load failed"); - // Connect attempt failed → drop the in-progress toast so - // the alert isn't competing with stale "Connecting…" UI. - self.dismiss_loading_page(); - self.set_status_page(super::StatusKind::Error, &crate::i18n::gettext("Failed"), &msg); - } - } - } - - /// Ctrl+F / Filter button — toggle the inline filter strip on - /// the active Browse tab. Strip lives inside the tab (always - /// constructed at init), so this is just a reveal flip. - pub(super) fn on_show_filter_dialog(&self) { - let Some(id) = self.selected_browse_tab_id() else { - self.show_toast(&crate::i18n::gettext("Open a table to filter rows.")); - return; - }; - self.dispatch_to_tab(id, BrowseTabInput::ToggleFilterStrip); - } - - pub(super) fn on_refresh_active_tab(&self) { - let Some(id) = self.selected_browse_tab_id() else { - return; - }; - let dirty = self - .change_trackers - .get(id) - .is_some_and(|tracker| tracker.pending_count() > 0); - if !dirty { - self.dispatch_to_tab(id, BrowseTabInput::Refresh); - return; - } - // F5 mid-edit: a refetch overwrites the model and silently - // drops every pending row edit / insert / delete. Confirm - // with a destructive AlertDialog mirroring the close-with- - // pending path so the user has to opt in to the data loss. - let dialog = adw::AlertDialog::new( - Some(&crate::i18n::gettext("Discard pending changes?")), - Some(&crate::i18n::gettext( - "Refreshing reloads the table from the database and drops every unsaved edit on this tab.", - )), - ); - dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); - dialog.add_response("discard", &crate::i18n::gettext("Discard and refresh")); - dialog.set_response_appearance("discard", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - let workspace_tabs = self.workspace_tabs.clone(); - dialog.connect_response(None, move |dlg: &adw::AlertDialog, response: &str| { - dlg.close(); - if response == "discard" - && let Some(controller) = workspace_tabs.borrow().get(&id).and_then(|t| t.browse_controller()) - { - let _ = controller.sender().send(BrowseTabInput::DiscardAndRefresh); - } - }); - dialog.present(Some(&self.window)); - } -} - -/// One browse tab's view of its table: the part of a page or count -/// that does not depend on the connection it runs on. -#[derive(Debug, Clone)] -struct BrowseRequest { - table: TableRef, - /// Empty until the tab has read them from the catalogue. - columns: Vec, - filter: FilterSet, - sort: Vec, - offset: u64, - limit: u64, -} - -impl BrowseRequest { - /// The tab's columns, or the catalogue's when the tab has not read - /// them yet: a page names every column it selects. - async fn columns(&self, lease: &ConnectionLease, options: &CallOptions) -> Result, DriverError> { - match self.columns.is_empty() { - true => lease.fetch_columns(&self.table, options.clone()).await, - false => Ok(self.columns.clone()), - } - } -} - -/// Why the tab's filter cannot be written for the current server, if it -/// cannot. -/// -/// Before the tab has its columns there is nothing to check against; -/// the page reads them and reports a filter that does not fit. -fn filter_problem(session: &Session, request: &BrowseRequest) -> Option { - if request.columns.is_empty() { - return None; - } - let dialect = session.current_dialect()?; - let mut builder = StatementBuilder::new(); - tablepro_core::build_filter(&mut builder, &*dialect, &request.columns, &request.filter) - .err() - .map(|error| error.to_string()) -} diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 8d0770e868..ddb586b28b 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -1,4 +1,3 @@ -mod browse; mod connect_flow; mod connection; mod import; @@ -249,7 +248,7 @@ pub struct TableTabSlot { pub page: adw::TabPage, pub schema: Option, pub table: String, - pub browse: Controller, + pub browse: BrowseTab, } /// A tab in the unified workspace. @@ -269,8 +268,8 @@ pub enum WorkspaceTab { } impl WorkspaceTab { - /// The Browse-side controller. Only `Table` slots carry one. - pub fn browse_controller(&self) -> Option<&Controller> { + /// The Browse tab. Only `Table` slots carry one. + pub fn browse_tab(&self) -> Option<&BrowseTab> { match self { WorkspaceTab::Table(s) => Some(&s.browse), _ => None, @@ -360,11 +359,6 @@ pub enum AppMsg { name: String, open_mode: OpenMode, }, - ColumnsLoaded(Uuid, Vec), - RowsLoaded(Uuid, u64, QueryResult), - /// `Some(tab_id)` for tab-scoped failures; `None` for app-level - /// failures (e.g. connect failure during open_saved). - LoadFailed(Option, String), ReloadConnections, ConnectionsLoaded(Vec), ConnectionListUnavailable, @@ -433,8 +427,6 @@ pub enum AppMsg { RefreshPage, ShowAbout, ShowPreferences, - /// Sort flipped on tab_id's grid for column idx. - RowCountLoaded(Uuid, u64), ExportResults { result: QueryResult, name: String, @@ -447,12 +439,6 @@ pub enum AppMsg { }, // ── Workspace tab routing ──────────────────────────────────────── - /// BrowseTab sub-component asked for its current page to be fetched. - FetchBrowsePage(Uuid), - /// BrowseTab needs schema columns. - FetchBrowseColumns(Uuid), - /// BrowseTab needs the row count. - FetchBrowseRowCount(Uuid), /// Any browse tab's columns changed; rebuild editor schema buffer. WorkspaceSchemaWordsChanged, /// User clicked the close-X on any workspace tab. @@ -618,26 +604,6 @@ pub enum AppMsg { ShowFilterDialog, } -/// Determines which icon and styling adw::StatusPage uses. -/// -/// Replaces the previous title-string sniffing in `set_status_page`, -/// which broke the moment a translation used different vocabulary -/// for "Failed" / "Error" / "No connection". -#[derive(Debug, Clone, Copy)] -pub(super) enum StatusKind { - Info, - Error, -} - -impl StatusKind { - fn icon(self) -> &'static str { - match self { - StatusKind::Info => crate::ui::icons::VIEW_GRID, - StatusKind::Error => crate::ui::icons::DIALOG_ERROR, - } - } -} - /// Tabs in either registry with unsaved changes. fn dirty_tabs(changes: &ChangeTrackerRegistry, structures: &StructureTrackerRegistry) -> Vec { let mut dirty = changes.dirty(); @@ -1282,7 +1248,7 @@ impl SimpleComponent for App { workspace_empty_cta.add_css_class("suggested-action"); workspace_empty_cta.add_css_class("pill"); let workspace_empty_page = adw::StatusPage::builder() - .icon_name(StatusKind::Info.icon()) + .icon_name(crate::ui::icons::VIEW_GRID) .title(crate::i18n::gettext("Select a table")) .description(crate::i18n::gettext( "Pick a table from the sidebar, or use the button below (Ctrl+T).", @@ -1429,14 +1395,7 @@ impl SimpleComponent for App { schema, name, open_mode, - } => self.on_select_table(schema, name, open_mode, sender), - AppMsg::ColumnsLoaded(tab_id, columns) => self.on_browse_columns_loaded(tab_id, columns), - AppMsg::RowsLoaded(tab_id, offset, result) => self.on_browse_rows_loaded(tab_id, offset, result), - AppMsg::LoadFailed(tab_id, msg) => self.on_browse_load_failed(tab_id, msg), - AppMsg::RowCountLoaded(tab_id, count) => self.on_browse_row_count_loaded(tab_id, count), - AppMsg::FetchBrowsePage(tab_id) => self.fetch_browse_page(tab_id, sender), - AppMsg::FetchBrowseColumns(tab_id) => self.fetch_browse_columns(tab_id, sender), - AppMsg::FetchBrowseRowCount(tab_id) => self.fetch_browse_row_count(tab_id, sender), + } => self.dispatch_select_table(schema, name, open_mode, sender), AppMsg::WorkspaceTabsChanged => self.on_workspace_tabs_changed(), AppMsg::WorkspaceSchemaWordsChanged => self.rebuild_schema_buffer(), AppMsg::ExecuteBrowseTransaction { @@ -1540,7 +1499,7 @@ impl SimpleComponent for App { } => self.on_structure_data_loaded(tab_id, *definition, dialect), AppMsg::StructureLoadFailed { tab_id, message } => self.on_structure_load_failed(tab_id, message), AppMsg::StructureTabDirtyChanged(tab_id, dirty) => self.refresh_structure_tab_dirty(tab_id, dirty), - AppMsg::SchemaChanged { schema, table } => self.on_schema_changed(schema, table, sender), + AppMsg::SchemaChanged { schema, table } => self.on_schema_changed(schema, table), AppMsg::ReloadConnections => self.on_reload_connections(sender), AppMsg::ConnectionListUnavailable => self.show_connection_list_banner(), AppMsg::SecretDeleteFailed(id) => self.on_secret_delete_failed(id, sender), diff --git a/linux/crates/app/src/ui/app/row_ops.rs b/linux/crates/app/src/ui/app/row_ops.rs index 4b009346fa..4ae874c610 100644 --- a/linux/crates/app/src/ui/app/row_ops.rs +++ b/linux/crates/app/src/ui/app/row_ops.rs @@ -1,5 +1,5 @@ +use relm4::ComponentSender; use relm4::adw::prelude::*; -use relm4::{ComponentController, ComponentSender}; use tablepro_core::meta::TableRef; use tablepro_core::save::{SavePlan, plan_save}; @@ -126,14 +126,16 @@ impl App { } pub(super) fn on_copy_row_as_insert(&self, tab_id: Uuid, row_position: u32) { - let (columns, snapshot, table) = { - let tabs = self.workspace_tabs.borrow(); - let Some(controller) = tabs.get(&tab_id).and_then(|t| t.browse_controller()) else { - return; - }; - let model = controller.model(); - (model.columns().to_vec(), model.snapshot(), model.table().to_string()) + let Some(browse) = self + .workspace_tabs + .borrow() + .get(&tab_id) + .and_then(|t| t.browse_tab()) + .cloned() + else { + return; }; + let (columns, snapshot, table) = (browse.columns(), browse.snapshot(), browse.table()); let Some(snapshot) = snapshot else { return }; let Some(row) = snapshot.rows.get(row_position as usize) else { return; diff --git a/linux/crates/app/src/ui/app/status_pages.rs b/linux/crates/app/src/ui/app/status_pages.rs index 73d88322d3..488642135c 100644 --- a/linux/crates/app/src/ui/app/status_pages.rs +++ b/linux/crates/app/src/ui/app/status_pages.rs @@ -49,14 +49,6 @@ impl App { } } - /// Convenience for `set_status_page(Error, ...)` and similar; in the - /// connected state, browse-tab errors flow through BrowseTabInput::ShowError. - /// Used here only for app-level (non-tab-scoped) failures — surfaces - /// as an alert dialog so the user actually notices. - pub(super) fn set_status_page(&self, _kind: super::StatusKind, title: &str, description: &str) { - self.show_error_alert(title, description); - } - pub(super) fn show_toast(&self, msg: &str) { self.toast_overlay.add_toast(adw::Toast::new(msg)); } diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index f2dd00c605..2a23572dfa 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -541,33 +541,27 @@ impl App { /// The schema changed under the window. The catalog forgets the /// table's columns and reads the sidebar's list again, and every - /// Browse tab open on the table reads it again. - pub(super) fn on_schema_changed( - &self, - schema: Option, - table: Option, - sender: ComponentSender, - ) { + /// Browse tab open on the table reads it again through its own + /// requests, which share the catalog's one column fetch. + pub(super) fn on_schema_changed(&self, schema: Option, table: Option) { let Some(session) = self.context.active.get() else { return; }; - if let Some(table_name) = table.as_deref() { - session.invalidate_table(&TableRef::new(schema.clone(), table_name)); - // The ids are collected under a short borrow, so the inputs - // that follow can borrow the tabs again. - let mut affected: Vec = Vec::new(); - for (id, tab) in self.workspace_tabs.borrow().iter() { - if let WorkspaceTab::Table(slot) = tab - && slot.schema.as_deref() == schema.as_deref() - && slot.table == table_name - { - affected.push(*id); - } - } - for id in affected { - sender.input(AppMsg::FetchBrowseColumns(id)); - sender.input(AppMsg::FetchBrowsePage(id)); - sender.input(AppMsg::FetchBrowseRowCount(id)); + if let Some(table) = table { + session.invalidate_table(&TableRef::new(schema.clone(), table.clone())); + let affected: Vec = self + .workspace_tabs + .borrow() + .values() + .filter_map(|tab| match tab { + WorkspaceTab::Table(slot) if slot.schema == schema && slot.table == table => { + Some(slot.browse.clone()) + } + _ => None, + }) + .collect(); + for browse in affected { + browse.send(crate::ui::browse_tab::BrowseTabInput::SchemaChanged); } } // The new list reaches the sidebar through the catalog watch. diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index 991f3eb49a..a7afaa9475 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -9,7 +9,7 @@ use relm4::{Component, ComponentController, ComponentSender, adw, gtk}; use uuid::Uuid; use crate::services::workspace_state::{self, ConnectionWorkspaceState, WorkspaceTabRecord}; -use crate::ui::browse_tab::{BrowseTab, BrowseTabInit, BrowseTabInput, BrowseTabOutput}; +use crate::ui::browse_tab::{BrowseTab, BrowseTabInit, BrowseTabInput, ExportRequest, SaveRequest}; use crate::ui::editor::{SqlEditor, SqlEditorInit, derive_tab_label}; use super::{ @@ -260,7 +260,10 @@ impl App { let connection_id = self.context.active.metadata().map(|metadata| metadata.id); let read_only = self.read_only; - let browse_init = BrowseTabInit { + let Some(session) = self.context.active.get() else { + return; + }; + let browse = BrowseTab::new(BrowseTabInit { tracker: self.change_trackers.register(tab_id), schema: schema.clone(), table: table.clone(), @@ -271,36 +274,12 @@ impl App { initial_sort: sort, column_widths: self.storage.column_widths().clone(), filter_settings: self.storage.filter_settings().clone(), - }; - let browse = BrowseTab::builder() - .launch(browse_init) - .forward(sender.input_sender(), move |out| match out { - BrowseTabOutput::FetchPage => AppMsg::FetchBrowsePage(tab_id), - BrowseTabOutput::FetchColumns => AppMsg::FetchBrowseColumns(tab_id), - BrowseTabOutput::FetchRowCount => AppMsg::FetchBrowseRowCount(tab_id), - BrowseTabOutput::StateChanged => AppMsg::WorkspaceTabsChanged, - BrowseTabOutput::CopyRowAsInsert { row_position } => AppMsg::CopyRowAsInsert { tab_id, row_position }, - BrowseTabOutput::CopyToClipboard(text) => AppMsg::CopyToClipboard(text), - BrowseTabOutput::ExportResults { result, name, target } => { - AppMsg::ExportResults { result, name, target } - } - BrowseTabOutput::SchemaWordsChanged(_words) => AppMsg::WorkspaceSchemaWordsChanged, - BrowseTabOutput::ShowSelectionAlert { title, body } => AppMsg::ShowAlert { title, body }, - BrowseTabOutput::ShowToast(msg) => AppMsg::ShowToast(msg), - BrowseTabOutput::DirtyChanged(dirty) => AppMsg::BrowseTabDirtyChanged(tab_id, dirty), - BrowseTabOutput::ExecuteTransaction { - table, - columns, - changes, - } => AppMsg::ExecuteBrowseTransaction { - tab_id, - table, - columns, - changes, - }, - }); + session, + tasks: self.context.tasks.clone(), + }); + connect_browse(&browse, tab_id, sender.input_sender()); - let page = tab_view.append(browse.widget()); + let page = tab_view.append(&browse); let label = qualified_browse_tab_label(self.sidebar_schemas_distinct(), schema.as_deref(), &table); page.set_title(&label); if let Some(tip) = browse_tab_tooltip(schema.as_deref(), &table, &label) { @@ -641,6 +620,7 @@ impl App { self.structure_saves_in_flight.borrow_mut().remove(&slot.id); } WorkspaceTab::Table(slot) => { + slot.browse.shutdown(); self.change_trackers.unregister(slot.id); self.structure_saves_in_flight.borrow_mut().remove(&slot.id); } @@ -787,6 +767,59 @@ impl App { self.append_table_tab(schema, name, 0, page_size, None, sender); } + /// Ctrl+F / Filter button: toggle the inline filter strip on the + /// active Browse tab. + pub(super) fn on_show_filter_dialog(&self) { + let Some(id) = self.selected_browse_tab_id() else { + self.show_toast(&crate::i18n::gettext("Open a table to filter rows.")); + return; + }; + self.dispatch_to_tab(id, BrowseTabInput::ToggleFilterStrip); + } + + pub(super) fn on_refresh_active_tab(&self) { + let Some(id) = self.selected_browse_tab_id() else { + return; + }; + let dirty = self + .change_trackers + .get(id) + .is_some_and(|tracker| tracker.pending_count() > 0); + if !dirty { + self.dispatch_to_tab(id, BrowseTabInput::Refresh); + return; + } + // A refetch replaces the rows and with them every pending edit, + // so F5 mid-edit asks first, as closing a dirty tab does. + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Discard pending changes?")), + Some(&crate::i18n::gettext( + "Refreshing reloads the table from the database and drops every unsaved edit on this tab.", + )), + ); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("discard", &crate::i18n::gettext("Discard and refresh")); + dialog.set_response_appearance("discard", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let workspace_tabs = self.workspace_tabs.clone(); + dialog.connect_response(None, move |dlg: &adw::AlertDialog, response: &str| { + dlg.close(); + if response != "discard" { + return; + } + let browse = workspace_tabs + .borrow() + .get(&id) + .and_then(|tab| tab.browse_tab()) + .cloned(); + if let Some(browse) = browse { + browse.send(BrowseTabInput::DiscardAndRefresh); + } + }); + dialog.present(Some(&self.window)); + } + /// Persist workspace tabs for the active connection. Walks /// `tab_view.pages()` for canonical display order (HashMap is /// unordered; user can drag-reorder). @@ -860,8 +893,7 @@ fn do_persist_workspace_state( if s.table.is_empty() { continue; } - let model = s.browse.model(); - let sort = model.current_sort(); + let sort = s.browse.current_sort(); WorkspaceTabRecord::Table { schema: s.schema.clone(), table: s.table.clone(), @@ -869,8 +901,8 @@ fn do_persist_workspace_state( // describe a Data-side Browse tab now. Structure // tabs persist as separate `Structure` records. mode: crate::services::workspace_state::PersistedTableMode::Data, - offset: model.current_offset(), - page_size: model.page_size(), + offset: s.browse.current_offset(), + page_size: s.browse.page_size(), sort_col: sort.map(|(c, _)| c), sort_asc: sort.map(|(_, a)| a), } @@ -938,11 +970,7 @@ impl App { // active mode) as candidates — the Browse-side controller is // present in both. Save / Discard / Find paths route through // this helper. - if slot.browse_controller().is_some() { - Some(id) - } else { - None - } + if slot.browse_tab().is_some() { Some(id) } else { None } } pub(super) fn selected_browse_slot_table(&self) -> Option<(Option, String)> { @@ -956,10 +984,8 @@ impl App { let mut words: Vec = self.table_names.clone(); let tabs = self.workspace_tabs.borrow(); for tab in tabs.values() { - if let Some(controller) = tab.browse_controller() { - for col in controller.model().columns() { - words.push(col.name.clone()); - } + if let Some(browse) = tab.browse_tab() { + words.extend(browse.columns().into_iter().map(|column| column.name)); } } words.sort_unstable(); @@ -1021,7 +1047,10 @@ impl App { for tab in self.workspace_tabs.borrow().values() { match tab { WorkspaceTab::Structure(slot) => self.structure_trackers.unregister(slot.id), - WorkspaceTab::Table(slot) => self.change_trackers.unregister(slot.id), + WorkspaceTab::Table(slot) => { + slot.browse.shutdown(); + self.change_trackers.unregister(slot.id); + } WorkspaceTab::Editor(_) => {} } } @@ -1052,13 +1081,14 @@ impl App { /// Browse-side controller in both legacy `Browse` slots and the /// unified `Table` slot. pub(super) fn dispatch_to_tab(&self, tab_id: Uuid, msg: BrowseTabInput) { - if let Some(controller) = self + let browse = self .workspace_tabs .borrow() .get(&tab_id) - .and_then(|t| t.browse_controller()) - { - let _ = controller.sender().send(msg); + .and_then(|t| t.browse_tab()) + .cloned(); + if let Some(browse) = browse { + browse.send(msg); } } @@ -1320,17 +1350,13 @@ fn browse_tab_tooltip(schema: Option<&str>, table: &str, label: &str) -> Option< fn describe_closed_tab(slot: &WorkspaceTab) -> Option { match slot { WorkspaceTab::Editor(s) => Some(ClosedTabDescriptor::Editor { query: s.query.clone() }), - WorkspaceTab::Table(s) => { - let model = s.browse.model(); - let sort = model.current_sort(); - Some(ClosedTabDescriptor::Table { - schema: s.schema.clone(), - table: s.table.clone(), - offset: model.current_offset(), - page_size: model.page_size(), - sort, - }) - } + WorkspaceTab::Table(s) => Some(ClosedTabDescriptor::Table { + schema: s.schema.clone(), + table: s.table.clone(), + offset: s.browse.current_offset(), + page_size: s.browse.page_size(), + sort: s.browse.current_sort(), + }), WorkspaceTab::Structure(s) => match s.mode { // New-Table drafts have no entity to point at and lose // their in-progress DDL on close — nothing to reopen. @@ -1422,6 +1448,46 @@ fn editor_tab_tooltip(query: &str, label: &str) -> Option { /// Forward an editor tab's signals into the window. The toast overlay /// is the window's, so a replaced query's Undo button lives there. +/// The browse tab's signals, as this window's inputs for the tab. +fn connect_browse(browse: &BrowseTab, tab_id: Uuid, input: &relm4::Sender) { + let state = input.clone(); + browse.connect_state_changed(move |_| state.emit(AppMsg::WorkspaceTabsChanged)); + let columns = input.clone(); + browse.connect_columns_changed(move |_| columns.emit(AppMsg::WorkspaceSchemaWordsChanged)); + let copy_row = input.clone(); + browse.connect_copy_row_as_insert(move |_, row_position| { + copy_row.emit(AppMsg::CopyRowAsInsert { tab_id, row_position }); + }); + let clipboard = input.clone(); + browse.connect_copy_to_clipboard(move |_, text| clipboard.emit(AppMsg::CopyToClipboard(text))); + let export = input.clone(); + browse.connect_export_results(move |_, ExportRequest { result, name, target }| { + export.emit(AppMsg::ExportResults { result, name, target }); + }); + let alert = input.clone(); + browse.connect_alert(move |_, title, body| alert.emit(AppMsg::ShowAlert { title, body })); + let toast = input.clone(); + browse.connect_toast(move |_, text| toast.emit(AppMsg::ShowToast(text))); + let dirty = input.clone(); + browse.connect_dirty_changed(move |_, is_dirty| dirty.emit(AppMsg::BrowseTabDirtyChanged(tab_id, is_dirty))); + let save = input.clone(); + browse.connect_save_requested( + move |_, + SaveRequest { + table, + columns, + changes, + }| { + save.emit(AppMsg::ExecuteBrowseTransaction { + tab_id, + table, + columns, + changes, + }); + }, + ); +} + fn connect_editor( editor: &SqlEditor, tab_id: Uuid, diff --git a/linux/crates/app/src/ui/browse_tab.rs b/linux/crates/app/src/ui/browse_tab.rs deleted file mode 100644 index 49e9f50a3f..0000000000 --- a/linux/crates/app/src/ui/browse_tab.rs +++ /dev/null @@ -1,2958 +0,0 @@ -use std::rc::Rc; - -use relm4::adw::prelude::*; -use relm4::gtk::glib; -use relm4::prelude::*; -use relm4::{adw, gtk}; -use uuid::Uuid; - -use tablepro_core::edit::{CellInput, EditContext, EditParseError}; -use tablepro_core::{ColumnInfo, QueryResult, Value}; - -use super::grid::{CellPreset, GridMsg, TabGridContext, build_column_view}; -use crate::services::change_tracker::{RowKey, StatementSource, TrackedChanges}; -use crate::services::tab_tracker_handle::TabTrackerHandle; -use crate::services::tracker_delta::{TrackerDelta, VisualChange}; -use crate::services::tracker_edit::TrackerEdit; - -const PAGE_SIZE_OPTIONS: &[u64] = &[100, 500, 1_000, 5_000, 10_000]; -const DEFAULT_PAGE_SIZE: u64 = 1_000; -/// Bulk-delete safety net: when the user marks at least this many -/// rows pending-delete in one shot, surface a confirmation dialog -/// before tracking. The marker is reversible via Discard / Ctrl+Z, -/// but a 200-row Ctrl+A → Delete sequence is destructive enough at -/// a glance that an explicit confirmation matches GNOME Files' -/// "Delete N items?" pattern. -const BULK_DELETE_CONFIRM_THRESHOLD: usize = 10; - -pub struct BrowseTabInit { - pub tracker: TabTrackerHandle, - pub schema: Option, - pub table: String, - pub connection_id: Option, - pub read_only: bool, - pub page_size: u64, - pub initial_offset: u64, - pub initial_sort: Option<(usize, bool)>, - pub column_widths: crate::persistence::ColumnWidthStore, - pub filter_settings: crate::persistence::FilterSettingsStore, -} - -pub struct BrowseTab { - tracker: TabTrackerHandle, - schema: Option, - table: String, - connection_id: Option, - read_only: bool, - column_widths: crate::persistence::ColumnWidthStore, - filter_settings: crate::persistence::FilterSettingsStore, - - current_offset: u64, - page_size: u64, - current_sort: Option<(usize, bool)>, - /// Server-side WHERE filter applied to every fetch. Persisted per - /// `(connection_id, schema, table)` via `services::filter_settings`. - /// Empty FilterSet means no WHERE clause; updates restart pagination - /// at offset 0 since filtered counts shift. - current_filter: tablepro_core::FilterSet, - current_columns: Vec, - current_result: Option, - current_selection: Option, - current_total_rows: Option, - - inner_stack: gtk::Stack, - grid_holder: gtk::Box, - /// Live reference to the current page's `gtk::ColumnView`. Replaced - /// on every `RowsLoaded`. Used by the inline-Insert flow to - /// scroll-to-and-focus the freshly-prepended draft row. - current_column_view: Option, - /// Column count at the time `current_column_view` was last built. - /// `render_grid_if_ready` compares this to `current_columns.len()` - /// to decide whether the cached view can be reused or needs a - /// full rebuild. Within a single tab the count never changes - /// after the first ColumnsLoaded; mismatch implies cold-path. - rendered_column_count: std::cell::Cell, - /// Persistent-state banners (per HIG: banners for state, toasts - /// for events). Pending-changes is communicated through the - /// ActionBar footer + tab-title bullet, not a banner — the banner - /// is reserved for constraint states (read-only, no-PK) the user - /// can't dismiss by saving. - read_only_banner: adw::Banner, - no_pk_banner: adw::Banner, - /// Last-emitted dirty state. PendingCountChanged fires on every - /// tracker mutation including count-only changes (2 → 3) where the - /// dirty flag hasn't actually flipped. Tracking the previous flag - /// here lets us emit `BrowseTabOutput::DirtyChanged` only on real - /// transitions and avoid redundant tab-title rewrites in App. - was_dirty: std::cell::Cell, - /// Row identity captured before a sort/save/refresh-driven reload - /// so the focused row can be re-selected and re-scrolled into view - /// once the new page lands. Persisted rows match by PK; drafts - /// match by draft_id (for the rare case where a draft is focused - /// when a sort happens). Cleared by `restore_focused_row`. - pending_focus_restore: std::cell::RefCell>, - paginator_label: gtk::Label, - /// Live count of selected rows. Hidden when 0 or 1 rows are - /// selected; shows "{n} selected" once the user shift-clicks - /// or Ctrl+clicks to build a multi-row selection. Updated via - /// the selection model's connect_selection_changed signal so it - /// stays in sync without polling. - selection_label: gtk::Label, - first_button: gtk::Button, - prev_button: gtk::Button, - next_button: gtk::Button, - last_button: gtk::Button, - /// Toolbar button that toggles the filter strip. Text-only - /// ("Filter") because adwaita-icon-theme has no canonical - /// symbolic icon for filtering and reusing a search/find icon - /// would clash with the universal Ctrl+F shortcut now bound to - /// this filter action. When the current filter has any rules, a - /// small count badge appears next to the word; hidden otherwise. - filter_button: gtk::Button, - /// Count badge inside `filter_button`. Hidden when the filter - /// is empty, otherwise reads `N` for N active rules. - filter_badge: gtk::Label, - /// Inline filter editor — slides in above the grid when - /// revealed. Owned per-tab so the rules editor doesn't lose - /// in-progress state if the user accidentally clicks outside it. - filter_strip: Option, - /// Insert row button — sits at the start of the paginator bar - /// (`gtk::ActionBar` pack_start), separated from the nav arrows - /// by the actionbar's start group. Delete affordance is gone - /// from the toolbar — right-click "Delete row" + the Delete key - /// shortcut cover the action surface (Files / Contacts pattern). - insert_button: gtk::Button, - /// Pending-changes footer (Save / Discard / count label) wrapped - /// in a `GtkRevealer` so it slides into view only when there are - /// unsaved edits. Lives as the BrowseTab's only bottom bar besides - /// the paginator; reveal flips inside `refresh_pending_bar`. - pending_revealer: gtk::Revealer, - save_button: gtk::Button, - discard_button: gtk::Button, - pending_label: gtk::Label, - grid_sender: relm4::Sender, - /// Set to true on init / refresh; flipped off after first RowsLoaded so - /// PageSizeChanged emits don't fire while the combo is being driven by - /// programmatic state restores. - suppress_combo_emit: Rc>, -} - -#[derive(Debug)] -pub enum BrowseTabInput { - /// Replace this tab's grid with the given page of rows. - RowsLoaded { - offset: u64, - result: QueryResult, - }, - /// Schema columns for the current table arrived (governs editability). - ColumnsLoaded(Vec), - /// Total row count for paginator label. - RowCountLoaded(u64), - /// Show an error status page. - ShowError(String), - /// Re-issue the fetch for this tab (F5). - Refresh, - /// Clear a multi-row selection (Esc when 2+ rows are selected - /// and no search bar / cell editor is active). Single-row - /// selections are intentionally preserved — unselecting the - /// only-row would strand the keyboard focus indicator. - ClearSelection, - /// Toggle the inline filter strip's reveal state. Wired to the - /// Filter button + Ctrl+F action. - ToggleFilterStrip, - /// User confirmed a new filter set in the filter strip (or hit - /// "Clear all" — that's an empty FilterSet). BrowseTab persists, - /// resets pagination to offset 0, refreshes chrome, and - /// re-fetches. - FilterApplied(tablepro_core::FilterSet), - /// User clicked First page (offset → 0). - FirstPage, - /// User clicked Prev page. - PrevPage, - /// User clicked Next page. - NextPage, - /// User clicked Last page (offset → last full page based on - /// row count). No-op if the row count isn't known yet. - LastPage, - /// Sort flipped on column idx (from grid sorter). - SortChanged { - col_idx: usize, - ascending: bool, - }, - /// Page size dropdown changed. - PageSizeChanged(u64), - /// User clicked the Insert button on this tab's paginator bar. - InsertRow, - /// Self-dispatched after `InsertRow` to grab focus on the newly- - /// inserted draft row's first editable cell. Sent through the - /// input queue so the handler reads `self.current_column_view` - /// fresh rather than capturing a potentially-stale reference into - /// an `idle_add_local_once` closure. - FocusInsertedDraft, - /// User clicked the Delete Selected button. - DeleteSelectedRow, - /// Cell-edit / set-null / delete-row / copy-as-insert events from - /// this tab's grid (forwarded from its own GridMsg channel). The - /// table is implicit — each tab's grid only ever fires for its - /// own table. - GridCellEdited { - row_position: u32, - col_index: usize, - new_value: String, - }, - /// Cell context-menu "Set Value". The grid names the preset; the - /// tab resolves it against the column's declared type, because - /// only the tab holds the column metadata that says whether an - /// empty string is a value the column can hold. - GridSetCellValue { - row_position: u32, - col_index: usize, - preset: CellPreset, - }, - /// The grid could not carry out a menu action in full and wants - /// to say so. - GridShowToast(String), - GridExportResults(QueryResult), - ExportCurrentPage, - GridDeleteRowAt { - row_position: u32, - }, - GridCopyRowAsInsert { - row_position: u32, - }, - /// Cell context-menu "Duplicate row" — clone the cell values - /// from `row_position` into a fresh draft row prepended to the - /// grid. PK / generated / auto-increment columns are blanked so - /// the duplicate doesn't inherit the source's identity. - DuplicateRow { - row_position: u32, - }, - GridCopyToClipboard(String), - /// Ctrl+Z on this tab. - Undo, - /// Ctrl+Shift+Z. - Redo, - /// User clicked Save: hand the tracker's changes to the App as one - /// `BrowseTabOutput::ExecuteTransaction`, which saves them as one - /// batch. - CommitSave, - /// User clicked Discard — clear all pending edits and refetch - /// the page so the grid shows committed values again. - DiscardAll, - /// Drop every pending edit, then reload the page and count. - DiscardAndRefresh, - /// Mark persisted rows for deletion and drop drafts, once a bulk - /// delete is confirmed. - MarkRowsDeleted { - rows: Vec<(RowKey, Vec)>, - drafts: Vec, - }, - /// Save command resolved successfully — clear tracker, refetch. - SaveCompleted, - /// Save command failed — surface error to the user; keep the - /// pending changeset intact so they can retry. - SaveFailed(String), - /// App-side mapping resolved a `DriverError::Transaction`'s - /// statement_index to a `StatementSource`. Find the matching row - /// in the current grid (by draft_id for inserts, PK for updates - /// and deletes) and scroll-and-select it. Best-effort: if the row - /// isn't on the current page (paginated past it, sorted away), - /// the alert dialog still tells the user which statement failed. - FlashErrorRow(StatementSource), - /// Ctrl+C with row(s) selected: serialize each selected row as - /// tab-separated cells and push to the system clipboard. Falls - /// through to GTK's default Ctrl+C if no rows are selected so - /// inline cell-text selection still copies the highlighted text. - CopySelectedRowsAsTsv, - /// Ctrl+V on the grid (focus not in a cell editor): show a toast - /// telling the user multi-row paste isn't supported. Cell-level - /// paste continues to work via the normal text-editor path. - PasteNotSupported, - /// Ctrl+A: select every visible row. - SelectAllRows, - /// Home / End: scroll-and-focus the first / last row of the - /// current page. Cell-level Home/End within a row would conflict - /// with the cell editor's text-edit behaviour, so we scope - /// these to row navigation only — matches how GtkColumnView - /// users expect Home/End to behave in a list context. - GoToFirstRow, - GoToLastRow, -} - -#[derive(Debug)] -pub enum BrowseTabOutput { - /// Tab needs the next page of rows fetched (state is in the slot). - FetchPage, - /// Tab needs schema columns fetched. - FetchColumns, - /// Tab needs the row count fetched. - FetchRowCount, - /// Display state changed in a way that should be persisted. - StateChanged, - /// Cell context-menu "Copy row as INSERT". - CopyRowAsInsert { row_position: u32 }, - /// Generic clipboard-copy request from grid. - CopyToClipboard(String), - /// "Export Results…" from the grid menu or the paginator button. - /// Carries the rows to write and a suggested file name stem. - ExportResults { - result: QueryResult, - name: String, - target: Option, - }, - /// Column-name vocabulary for editor autocomplete; App merges across tabs. - SchemaWordsChanged(Vec), - /// Show a generic info dialog for "Cannot edit / select exactly one row". - ShowSelectionAlert { title: String, body: String }, - /// Show a transient toast — used for inline cell-input validation - /// errors ("Invalid date format" etc.) where a modal alert is too - /// heavy for the user's intent. - ShowToast(String), - /// Pending-changeset count crossed the empty / non-empty boundary. - /// `true` = at least one pending edit; the App-side handler - /// prefixes the tab title with the GNOME-Text-Editor "•" dot. - DirtyChanged(bool), - /// Save the tracker's changes as one batch. The columns are the ones - /// the change set was built against, so the plan binds the same - /// shape; `changes` maps a failing step back to its grid row. - ExecuteTransaction { - table: tablepro_core::meta::TableRef, - columns: Vec, - changes: TrackedChanges, - }, -} - -impl BrowseTab { - pub fn snapshot(&self) -> Option { - self.current_result.clone() - } - - /// The table a browse tab's rows came from, so an export can - /// write them back as `INSERT` statements. - fn sql_target(&self) -> crate::ui::export_dialog::SqlTarget { - crate::ui::export_dialog::SqlTarget { - table: self.table.clone(), - } - } - - fn export_name(&self) -> String { - match &self.schema { - Some(s) => format!("{s}.{}", self.table), - None => self.table.clone(), - } - } - - /// The tracker context the grid renders through. Copy and export - /// read the same context, so what leaves the tab is what the user - /// is looking at, pending edits included. - fn grid_context(&self) -> TabGridContext { - TabGridContext { - tracker: Some(self.tracker.clone()), - column_widths: self - .connection_id - .map(|connection_id| crate::ui::grid::ColumnWidthBinding { - store: self.column_widths.clone(), - connection_id, - schema: self.schema.clone(), - table: self.table.clone(), - }), - pk_col_indices: self - .current_columns - .iter() - .enumerate() - .filter(|(_, c)| c.primary_key) - .map(|(i, _)| i) - .collect(), - } - } - - /// What the paginator's Export button writes. Built from the live - /// grid so it matches the context menu's Export Results row for - /// row; falls back to the fetch itself before the grid exists. - fn export_payload(&self) -> Option { - let current = self.current_result.as_ref()?; - let Some(column_view) = self.current_column_view.as_ref() else { - return Some(current.clone()); - }; - Some(super::grid::export_snapshot( - column_view, - ¤t.columns, - current.truncated, - &self.grid_context(), - )) - } - - /// Resolve a "Set Value" preset against the column it lands in. - /// The grid offers Empty only on free-text columns, so the - /// fallback here is for the keyboard and action-activation paths: - /// an empty string means NULL on a column that takes one, and is - /// refused on a column that does not, exactly as typing an empty - /// value into the cell would be. - fn resolve_cell_preset(&self, preset: CellPreset, col_index: usize, context: EditContext) -> Result { - match preset { - CellPreset::Null => Ok(Value::Null), - CellPreset::Empty => parse_input_for_column("", self.current_columns.get(col_index), context), - } - } - - /// Whether an edit at this position lands in a draft row, which is - /// the one place an empty cell can mean the server's default. - fn edit_context_at(&self, row_position: u32) -> EditContext { - match self - .row_object_at(row_position) - .is_some_and(|row| row.draft_id().is_some()) - { - true => EditContext::InsertDraft, - false => EditContext::UpdateRow, - } - } - - pub fn columns(&self) -> &[ColumnInfo] { - &self.current_columns - } - - pub fn table_label(&self) -> String { - match &self.schema { - Some(s) => format!("{s}.{}", self.table), - None => self.table.clone(), - } - } - - pub fn schema(&self) -> Option<&str> { - self.schema.as_deref() - } - - pub fn table(&self) -> &str { - &self.table - } - - pub fn current_offset(&self) -> u64 { - self.current_offset - } - - pub fn page_size(&self) -> u64 { - self.page_size - } - - pub fn current_sort(&self) -> Option<(usize, bool)> { - self.current_sort - } - - pub fn current_filter(&self) -> &tablepro_core::FilterSet { - &self.current_filter - } - - /// Build the navigation toolbar — Prev / Next / page label / page - /// size dropdown / Filter / Export. Row-level mutations live - /// elsewhere: Insert in the per-table HeaderBar (canonical GNOME - /// "Add" placement), Delete via right-click + the Delete key. - fn build_paginator(sender: ComponentSender, page_size: u64) -> Paginator { - // First / Last bracket the Prev / Next pair. Tables of - // millions of rows make Last especially valuable — without - // it the user has to spam Next to reach the bottom. Same - // visual + interaction model as TablePlus / DataGrip / - // DBeaver. Last stays disabled until the row count loads. - // Insert row sits at the very start of the paginator's - // pack_start group — clearly separated from the nav arrows by - // its position and by the GtkActionBar's start group spacing, - // so a mis-aim toward Next doesn't land on Insert. - let insert_button = gtk::Button::builder() - .icon_name(crate::ui::icons::LIST_ADD) - .tooltip_text(crate::i18n::gettext("Insert row (Ctrl+N)")) - .sensitive(false) - .build(); - insert_button.add_css_class("flat"); - let sender_for_insert = sender.clone(); - insert_button.connect_clicked(move |_| sender_for_insert.input(BrowseTabInput::InsertRow)); - - let first_button = gtk::Button::builder() - .icon_name(crate::ui::icons::GO_FIRST) - .tooltip_text(crate::i18n::gettext("First page")) - .sensitive(false) - .build(); - let prev_button = gtk::Button::builder() - .icon_name(crate::ui::icons::GO_PREVIOUS) - .tooltip_text(crate::i18n::gettext("Previous page (Page Up)")) - .sensitive(false) - .build(); - let next_button = gtk::Button::builder() - .icon_name(crate::ui::icons::GO_NEXT) - .tooltip_text(crate::i18n::gettext("Next page (Page Down)")) - .sensitive(false) - .build(); - let last_button = gtk::Button::builder() - .icon_name(crate::ui::icons::GO_LAST) - .tooltip_text(crate::i18n::gettext("Last page")) - .sensitive(false) - .build(); - let paginator_label = gtk::Label::builder().build(); - paginator_label.add_css_class("dim-label"); - paginator_label.set_accessible_role(gtk::AccessibleRole::Status); - - // Selection count badge — sits beside the paginator label. - // Hidden when 0–1 rows selected; appears when the user - // shift-clicks a range or ctrl-clicks to multi-select. - // `accent` class draws the user's eye to it; AccessibleRole - // Status is the same role we use for the paginator label so - // a screen reader announces both as live regions. - let selection_label = gtk::Label::builder().visible(false).build(); - selection_label.add_css_class("accent"); - selection_label.add_css_class("caption-heading"); - selection_label.set_accessible_role(gtk::AccessibleRole::Status); - selection_label.set_margin_start(12); - - // Use thousands-separated labels (100 / 500 / 1,000 / 5,000 / - // 10,000) instead of "1 K" abbreviations. With a visible - // "Rows:" label inline (below) the dropdown's purpose is - // obvious without needing a tooltip, matching how Evince - // labels its zoom dropdown. - let page_size_labels: Vec = PAGE_SIZE_OPTIONS.iter().map(|n| format_thousands(*n)).collect(); - let page_size_strs: Vec<&str> = page_size_labels.iter().map(String::as_str).collect(); - let page_size_combo = gtk::DropDown::from_strings(&page_size_strs); - let initial_idx = PAGE_SIZE_OPTIONS - .iter() - .position(|n| *n == page_size) - .unwrap_or_else(|| { - PAGE_SIZE_OPTIONS - .iter() - .position(|n| *n == DEFAULT_PAGE_SIZE) - .unwrap_or(2) - }) as u32; - page_size_combo.set_selected(initial_idx); - let sender_for_size = sender.clone(); - page_size_combo.connect_selected_notify(move |dd| { - let idx = dd.selected() as usize; - if let Some(&size) = PAGE_SIZE_OPTIONS.get(idx) { - sender_for_size.input(BrowseTabInput::PageSizeChanged(size)); - } - }); - let page_size_label = gtk::Label::builder().label(crate::i18n::gettext("Rows:")).build(); - page_size_label.add_css_class("dim-label"); - - let sender_for_first = sender.clone(); - first_button.connect_clicked(move |_| sender_for_first.input(BrowseTabInput::FirstPage)); - let sender_for_prev = sender.clone(); - prev_button.connect_clicked(move |_| sender_for_prev.input(BrowseTabInput::PrevPage)); - let sender_for_next = sender.clone(); - next_button.connect_clicked(move |_| sender_for_next.input(BrowseTabInput::NextPage)); - let sender_for_last = sender.clone(); - last_button.connect_clicked(move |_| sender_for_last.input(BrowseTabInput::LastPage)); - - // Paginator lives in a native `gtk::ActionBar` to match the - // mutations bar and the Structure tab's bottom action bar. - // Prev/Next/label are start-packed; page-size + export are - // end-packed — which gives the same visual as before but - // through the toolkit's intended widget so spacing, dim-label - // background, and high-contrast theming come for free. - let paginator_bar = gtk::ActionBar::new(); - - let export_button = gtk::Button::builder() - .icon_name(crate::ui::icons::DOCUMENT_SAVE) - .tooltip_text(crate::i18n::gettext("Export results")) - .build(); - export_button.add_css_class("flat"); - let export_sender = sender.clone(); - export_button.connect_clicked(move |_| export_sender.input(BrowseTabInput::ExportCurrentPage)); - - // Filter button — opens the rule editor for server-side WHERE. - // Action `win.open-filter` is registered in app/mod.rs and - // reads the active tab's controller, so the button implicitly - // targets this tab when this tab is active. - // - // Text-only label (no icon) because there is no canonical GNOME - // symbolic icon for "filter rows" in adwaita-icon-theme — the - // alternatives (system-search-symbolic, edit-find-symbolic) - // clash with Ctrl+F (now bound here). GNOME HIG accepts text- - // labeled toolbar buttons; the surrounding paginator strip is - // already text-heavy (`Rows 1–12 of 12`, `Rows: 100`) so a - // text label reads as native here. The `filter_badge` label - // shows the active rule count next to the word when ≥1 rule - // applies; hidden otherwise. - let filter_label = gtk::Label::new(Some(&crate::i18n::gettext("Filter"))); - let filter_badge = gtk::Label::builder().label("").visible(false).build(); - filter_badge.add_css_class("numeric"); - filter_badge.add_css_class("caption-heading"); - filter_badge.add_css_class("dim-label"); - let filter_box = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(6) - .build(); - filter_box.append(&filter_label); - filter_box.append(&filter_badge); - let filter_button = gtk::Button::builder() - .tooltip_text(crate::i18n::gettext("Filter rows (Ctrl+F)")) - .action_name("win.open-filter") - .child(&filter_box) - .build(); - filter_button.add_css_class("flat"); - - // First / Prev / Next / Last sit in a `linked` group so they - // read as one navigation control — same pattern GNOME Files - // uses on its back/forward toolbar buttons. - let nav_box = gtk::Box::builder().orientation(gtk::Orientation::Horizontal).build(); - nav_box.add_css_class("linked"); - nav_box.append(&first_button); - nav_box.append(&prev_button); - nav_box.append(&next_button); - nav_box.append(&last_button); - - paginator_bar.pack_start(&insert_button); - paginator_bar.pack_start(&nav_box); - paginator_bar.pack_start(&paginator_label); - paginator_bar.pack_start(&selection_label); - paginator_bar.pack_end(&export_button); - paginator_bar.pack_end(&filter_button); - paginator_bar.pack_end(&page_size_combo); - paginator_bar.pack_end(&page_size_label); - - Paginator { - bar: paginator_bar, - insert_button, - first_button, - prev_button, - next_button, - last_button, - filter_button, - filter_badge, - paginator_label, - selection_label, - } - } - - /// Pending-changes footer: a `gtk::ActionBar` wrapped in a - /// `GtkRevealer` so the entire bar slides in only when there are - /// unsaved edits. Mirrors GNOME Text Editor / Builder's behaviour - /// of revealing a transient action footer rather than reserving a - /// permanent strip for occasionally-used controls. - /// - /// Layout: pending-count label on the left ("3 unsaved changes"), - /// Discard + Save on the right (Save is `.suggested-action`). - fn build_pending_revealer(sender: ComponentSender) -> PendingRevealer { - let pending_label = gtk::Label::new(None); - pending_label.add_css_class("dim-label"); - pending_label.add_css_class("caption"); - - let discard_button = gtk::Button::builder() - .label(crate::i18n::gettext("Discard")) - .tooltip_text(crate::i18n::gettext("Discard all pending edits")) - .build(); - let sender_for_discard = sender.clone(); - discard_button.connect_clicked(move |_| sender_for_discard.input(BrowseTabInput::DiscardAll)); - - let save_button = gtk::Button::builder() - .label(crate::i18n::gettext("Save")) - .tooltip_text(crate::i18n::gettext("Save pending edits (Ctrl+S)")) - .build(); - save_button.add_css_class("suggested-action"); - let sender_for_save = sender; - save_button.connect_clicked(move |_| sender_for_save.input(BrowseTabInput::CommitSave)); - - let bar = gtk::ActionBar::new(); - bar.pack_start(&pending_label); - bar.pack_end(&save_button); - bar.pack_end(&discard_button); - - let revealer = gtk::Revealer::builder() - .transition_type(gtk::RevealerTransitionType::SlideUp) - .transition_duration(150) - .reveal_child(false) - .child(&bar) - .build(); - - PendingRevealer { - widget: revealer, - save_button, - discard_button, - pending_label, - } - } - - /// Toggle visibility / label of the pending-changeset cluster in - /// the mutation bar. Hidden when there are no pending edits; shows - /// "{n} unsaved change(s)" with Save (.suggested-action) + Discard. - /// The tab-title bullet (App-side) plus this footer cluster cover - /// the dirty-state communication — no banner. - fn refresh_pending_bar(&self, count: usize) { - let visible = count > 0; - if visible { - let label = if count == 1 { - crate::i18n::gettext("1 unsaved change") - } else { - crate::i18n::ngettext_f( - "{n} unsaved change", - "{n} unsaved changes", - count as u32, - &[("n", &count.to_string())], - ) - }; - self.pending_label.set_label(&label); - } - // Slide the whole footer in/out as one unit instead of - // toggling each child's visibility. GtkRevealer animates the - // reveal so the bar doesn't pop into existence; the bar's - // own children stay always-visible inside it. - self.pending_revealer.set_reveal_child(visible); - self.refresh_banner_visibility(); - } - - /// Apply what a tracker call changed: the draft rows the store gains - /// or loses, then a rebind of each touched row, then the footer. - fn apply_tracker_delta(&self, delta: TrackerDelta, sender: &ComponentSender) { - if let Some(store) = self.list_store() { - for change in &delta.visual { - apply_visual_change(&store, change); - } - } - for key in &delta.rows { - if let Some(position) = self.find_row_position_by_key(key) { - self.refresh_row_at(position); - } - } - self.sync_pending_chrome(delta.pending_count, sender); - } - - /// The footer and the tab's dirty flag, which App turns into the - /// title bullet. App hears only real transitions. - fn sync_pending_chrome(&self, count: usize, sender: &ComponentSender) { - self.refresh_pending_bar(count); - let dirty = count > 0; - if dirty != self.was_dirty.replace(dirty) { - let _ = sender.output(BrowseTabOutput::DirtyChanged(dirty)); - } - } - - fn apply_edit(&self, edit: TrackerEdit, sender: &ComponentSender) { - let delta = self.tracker.apply(edit); - self.apply_tracker_delta(delta, sender); - } - - /// A typed or preset value for one cell. A draft keeps the value on - /// its own row; a persisted row keeps it in the tracker. - fn edit_cell(&self, row_position: u32, col: usize, value: Value, sender: &ComponentSender) { - if let Some(draft_id) = self.row_object_at(row_position).and_then(|row| row.draft_id()) { - self.apply_edit(TrackerEdit::DraftCell { draft_id, col, value }, sender); - return; - } - let Some((row_key, row)) = self.row_key_at(row_position) else { - return; - }; - let Some(original) = row.get(col).cloned() else { - return; - }; - self.apply_edit( - TrackerEdit::Cell { - row_key, - col, - original, - value, - }, - sender, - ); - } - - fn reload(&self, sender: &ComponentSender) { - self.capture_focus_for_restore(); - self.show_loading_inner( - &crate::i18n::gettext("Loading…"), - &crate::i18n::gettext_f("Fetching rows from {table}", &[("table", &self.table_label())]), - ); - let _ = sender.output(BrowseTabOutput::FetchPage); - let _ = sender.output(BrowseTabOutput::FetchRowCount); - } - - /// Reveal at most ONE banner at a time. Read-only takes priority - /// over no-PK because read-only blocks every kind of edit. - fn refresh_banner_visibility(&self) { - let read_only = self.read_only; - let no_pk = self.current_columns.iter().any(|c| !c.primary_key) - && !self.current_columns.is_empty() - && !self.current_columns.iter().any(|c| c.primary_key); - self.read_only_banner.set_revealed(read_only); - self.no_pk_banner.set_revealed(!read_only && no_pk); - } - - /// Walk the selection → ListStore chain to expose the underlying - /// store for direct mutation (used by the inline-Insert path to - /// prepend a draft row). - fn list_store(&self) -> Option { - let selection = self.current_selection.as_ref()?; - selection.model()?.downcast::().ok() - } - - /// Notify the chain (ListStore → SelectionModel → ColumnView) that - /// the row at `pos` has changed so the view rebinds its cells. - /// - /// Why: per GTK4 docs, `items-changed` "should never be emitted - /// directly by users of the model". Splicing a fresh RowObject - /// into the underlying `gio::ListStore` is the canonical way to - /// force the downstream chain to invalidate caches and rebind. - fn refresh_row_at(&self, pos: u32) { - let row_obj = self.row_object_at(pos); - let store = self.list_store(); - let store_pos = match (row_obj.as_ref(), store.as_ref()) { - (Some(r), Some(s)) => s.find(r), - _ => None, - }; - // ColumnView's list-item-manager keeps a per-listitem - // cached pointer to the model item it's currently bound - // to. When it receives items-changed for a position, it - // re-fetches the model item at that position and - // **compares pointers**: if same identity, it skips - // unbind/bind because the bound item "didn't actually - // change". Mutating cells *inside* the existing - // RowObject (which is what set_cell does) keeps the - // pointer constant — that's why neither plain - // `items_changed` nor `splice(pos, 1, &[same_row_obj])` - // forces a rebind here. - // - // The fix: substitute a freshly-allocated RowObject - // carrying the post-mutation cells. Different GObject - // identity → list-item-manager invalidates the cache, - // unbinds the old listitem widget, rebinds with the - // new item, connect_bind fires, label.set_text picks up - // the reverted value. Atomic via splice: one items- - // changed emission, no flicker, no scroll jump. - if let (Some(store), Some(store_pos), Some(old)) = (store, store_pos, row_obj) { - let cells = old.cells_clone(); - let replacement = match old.draft_id() { - Some(id) => super::row_object::RowObject::new_draft(id, cells), - None => super::row_object::RowObject::new(cells), - }; - store.splice(store_pos, 1, &[replacement]); - } - } - - /// Look up the live `RowObject` at a selection-model position. - /// Used to detect whether a row is a draft (`draft_id().is_some()`) - /// vs a persisted row, and to mutate draft cells in place when - /// the user types into them. - fn row_object_at(&self, position: u32) -> Option { - let model = self.current_selection.as_ref()?.model()?; - model.item(position)?.downcast::().ok() - } - - /// Locate the row in the current model that matches a given - /// `RowKey`. Drafts match by `draft_id`; persisted rows match by - /// recomputing the row's PK key-tuple from cell values and - /// comparing. Returns the position in the (filtered) selection - /// model, or `None` if the row isn't on the current page. - fn find_row_position_by_key(&self, key: &RowKey) -> Option { - use crate::services::change_tracker::KeyValue; - let selection = self.current_selection.as_ref()?; - let model = selection.model()?; - let pk_indices: Vec = self - .current_columns - .iter() - .enumerate() - .filter(|(_, c)| c.primary_key) - .map(|(i, _)| i) - .collect(); - let n_items = model.n_items(); - for i in 0..n_items { - let Some(item) = model.item(i) else { continue }; - let Ok(row) = item.downcast::() else { - continue; - }; - let matched = match key { - RowKey::Draft(id) => row.draft_id() == Some(*id), - RowKey::Persisted(target_keys) => { - if row.draft_id().is_some() || pk_indices.is_empty() { - false - } else { - let row_keys: Vec = pk_indices - .iter() - .map(|&col_idx| tablepro_core::value::ValueIdentity::new(row.cell_value(col_idx))) - .collect(); - row_keys == *target_keys - } - } - }; - if matched { - return Some(i); - } - } - None - } - - /// Scroll to the row behind a failed statement and flash it. A row - /// paged, sorted or filtered away is not found, and the alert alone - /// tells the user which statement failed. - fn flash_error_row(&self, source: &StatementSource) { - let key = match source { - StatementSource::Insert { draft_id } => RowKey::Draft(*draft_id), - StatementSource::Update { row_key } | StatementSource::Delete { row_key } => row_key.clone(), - }; - let Some(position) = self.find_row_position_by_key(&key) else { - return; - }; - if let Some(selection) = self.current_selection.as_ref() { - selection.select_item(position, true); - } - if let Some(cv) = self.current_column_view.as_ref() { - cv.scroll_to( - position, - None, - gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, - None, - ); - } - let generation = self.tracker.flash_error_row(key); - self.refresh_row_at(position); - let tracker = self.tracker.clone(); - let selection_for_clear = self.current_selection.clone(); - // Matches the length of the flash animation in the stylesheet. - glib::timeout_add_local_once(std::time::Duration::from_millis(1800), move || { - if !tracker.clear_error_row_if(generation) { - return; - } - if let Some(selection) = selection_for_clear - && let Some(model) = selection.model() - && let Some(row_obj) = model - .item(position) - .and_then(|o| o.downcast::().ok()) - && let Some(store) = model.downcast::().ok() - && let Some(store_pos) = store.find(&row_obj) - { - store.items_changed(store_pos, 1, 1); - } - }); - } - - /// Snapshot the currently focused/first-selected row's identity. - /// Called before any operation that triggers a `RowsLoaded` reload - /// (sort flip, save, F5 refresh) so we can re-anchor the user's - /// view to the same row after the new page renders. - fn capture_focus_for_restore(&self) { - // If a previous capture is still pending (sort+save fire in - // quick succession before the first reload's restore runs), keep - // the original capture. The first user-visible focus should - // anchor to where they were before triggering the chain — not - // wherever focus drifted mid-rebuild. - if self.pending_focus_restore.borrow().is_some() { - return; - } - let Some(selection) = self.current_selection.as_ref() else { - return; - }; - let bitset = selection.selection(); - if bitset.size() == 0 { - return; - } - let pos = bitset.nth(0); - let Some(model) = selection.model() else { return }; - let Some(item) = model.item(pos) else { return }; - let Ok(row) = item.downcast::() else { - return; - }; - let key = if let Some(draft_id) = row.draft_id() { - Some(RowKey::Draft(draft_id)) - } else { - let pk_indices: Vec = self - .current_columns - .iter() - .enumerate() - .filter(|(_, c)| c.primary_key) - .map(|(i, _)| i) - .collect(); - if pk_indices.is_empty() { - None - } else { - let pk_values: Vec = pk_indices.iter().map(|&i| row.cell_value(i)).collect(); - RowKey::from_pk_values(&pk_values) - } - }; - *self.pending_focus_restore.borrow_mut() = key; - } - - /// Grab focus + start-editing on the freshly-inserted draft row's - /// Scroll + focus + select the freshly-prepended draft row, then - /// open its first editable cell for input. Called via - /// `BrowseTabInput::FocusInsertedDraft` rather than directly so we - /// read the current `column_view` / `selection` state rather than - /// a captured-by-value reference that could go stale if a - /// RowsLoaded fires between Insert and the deferred focus. The - /// whole sequence is queued via `idle_add_local_once` so the - /// inner-stack flip (empty → grid) has time to allocate the - /// ScrolledWindow's adjustments before `cv.scroll_to(...)` runs. - fn focus_inserted_draft(&self) { - let Some(cv) = self.current_column_view.clone() else { - return; - }; - let selection = self.current_selection.clone(); - glib::idle_add_local_once(move || { - // At idle time the column-view may already be detached - // (rare race, e.g. user pressed F5 between InsertRow and - // this idle). Bail silently — the row was inserted; only - // the auto-edit affordance is missed. - if cv.root().is_none() { - return; - } - // New drafts always land at position 0 (prepended). - if let Some(sel) = selection.as_ref() { - sel.select_item(0, true); - } - cv.scroll_to( - 0, - None, - gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, - None, - ); - let Some(window) = cv.root().and_then(|r| r.dynamic_cast::().ok()) else { - return; - }; - let Some(focused) = gtk::prelude::GtkWindowExt::focus(&window) else { - return; - }; - if let Ok(label) = focused.dynamic_cast::() { - if label.text().as_str() == super::grid::editable_null_sentinel() { - label.set_text(""); - } - label.start_editing(); - } - // Bool draft (CheckButton focused) needs no edit-mode - // dance; clicking / Space toggles natively. - }); - } - - /// Re-select and scroll-to the row captured by - /// `capture_focus_for_restore`, if it's still on the page after - /// the reload. Silently no-ops if the row was filtered out, sorted - /// to a different page, or removed by the commit. Always clears - /// the captured key so it doesn't bleed into the next reload. - fn restore_focused_row(&self) { - let Some(key) = self.pending_focus_restore.borrow_mut().take() else { - return; - }; - let Some(position) = self.find_row_position_by_key(&key) else { - return; - }; - if let Some(selection) = self.current_selection.as_ref() { - selection.select_item(position, true); - } - if let Some(cv) = self.current_column_view.as_ref() { - cv.scroll_to(position, None, gtk::ListScrollFlags::FOCUS, None); - } - } - - /// Build the `ColumnView` if we have both the schema (`current_columns`, - /// from `ColumnsLoaded`) and the current page's data (`current_result`, - /// from `RowsLoaded`). Until both are present the `inner_stack` stays - /// on the "loading" status page so the user can't interact with cells - /// whose editability map is wrong. - fn render_grid_if_ready(&mut self, sender: ComponentSender) { - let Some(result) = self.current_result.clone() else { - return; - }; - if self.current_columns.is_empty() { - // Schema not yet loaded — keep the loading status visible. - // ColumnsLoaded will re-invoke this when it arrives. - return; - } - - // Fast path: column structure hasn't changed since the last - // render (typical for sort flips / page changes / save reloads - // within one tab). Reuse the existing `ColumnView` and only - // refresh the underlying `ListStore`. Saves O(N×M) factory - // recreations and selection rebuilds per page change. - if self.column_view_matches_current_columns() - && let Some(store) = self.list_store() - { - self.refresh_grid_data(&result, &store); - self.refresh_grid_chrome(&result); - self.restore_focused_row(); - let _ = sender.output(BrowseTabOutput::StateChanged); - return; - } - - // Cold path: first render for this tab, or column structure - // changed (rare in practice — would require schema migration - // mid-session). Build the full column-view scaffolding. - clear_box(&self.grid_holder); - let tab_ctx = self.grid_context(); - let (column_view, selection) = build_column_view( - &result, - &self.current_columns, - self.grid_sender.clone(), - !self.read_only, - self.current_sort, - Some(self.grid_sender.clone()), - tab_ctx, - ); - self.current_selection = Some(selection); - self.current_column_view = Some(column_view.clone()); - self.rendered_column_count.set(self.current_columns.len()); - - // Selection-changed signal updates the count badge and the - // Delete button's tooltip live. The new MultiSelection is a - // fresh instance per rebuild, so the previous binding (if - // any) drops with the old selection — no leak. - let selection_label_for_signal = self.selection_label.clone(); - if let Some(sel) = self.current_selection.as_ref() { - sel.connect_selection_changed(move |sel, _, _| { - let n = sel.selection().size() as u32; - update_selection_chrome(&selection_label_for_signal, n); - }); - // Page rebuild clears MultiSelection's bitset; reset the - // chrome explicitly so a stale "5 selected" doesn't linger. - update_selection_chrome(&self.selection_label, 0); - } - - // Re-prepend any pending draft rows so they survive page changes, - // sort flips, and F5 refresh. The tracker is the canonical source - // of truth for drafts; the grid model is rebuilt fresh on every - // RowsLoaded so without this step the drafts vanish visually - // while the tracker still holds them, leading to confused state. - self.reprepend_drafts(); - - let scrolled = gtk::ScrolledWindow::builder() - .child(&column_view) - .hexpand(true) - .vexpand(true) - .build(); - self.grid_holder.append(&scrolled); - self.refresh_grid_chrome(&result); - self.restore_focused_row(); - let _ = sender.output(BrowseTabOutput::StateChanged); - } - - /// True when the cached `ColumnView` is structurally compatible - /// with `current_columns` and can have its data swapped without a - /// full rebuild. Within a single tab this is always true after - /// the first render — `current_columns` only mutates on - /// ColumnsLoaded, which fires once per (table, connection) open. - fn column_view_matches_current_columns(&self) -> bool { - self.current_column_view.is_some() && self.rendered_column_count.get() == self.current_columns.len() - } - - /// Replace the rows in the existing `ListStore` without touching - /// the columns / factories / selection model. Drafts are - /// re-prepended so they survive the swap. - fn refresh_grid_data(&self, result: &QueryResult, store: >k::gio::ListStore) { - store.remove_all(); - for row in &result.rows { - store.append(&super::row_object::RowObject::new(row.clone())); - } - self.reprepend_drafts(); - } - - /// Update paginator label, button sensitivity, and stack child — - /// chrome that depends on the result but not the column structure. - fn refresh_grid_chrome(&self, result: &QueryResult) { - self.refresh_crud_buttons(); - self.update_paginator_label(); - let on_first_page = self.current_offset == 0; - self.first_button.set_sensitive(!on_first_page); - self.prev_button.set_sensitive(!on_first_page); - let n_rows = result.rows.len() as u64; - self.next_button.set_sensitive(n_rows == self.page_size); - // Last only enables when we know the total AND we aren't - // already there. Without a known total, the button stays - // disabled — matches `RowCountLoaded`-gated UX everywhere - // else. - let last_target = self - .current_total_rows - .filter(|t| *t > 0) - .map(|t| (t - 1) / self.page_size * self.page_size); - self.last_button - .set_sensitive(last_target.is_some_and(|target| self.current_offset != target)); - - self.refresh_inner_stack_visibility(); - self.suppress_combo_emit.set(false); - } - - /// Switch the inner stack to the grid once the first page has - /// loaded. Previously this helper also flipped to a dedicated - /// "empty" AdwStatusPage when there were 0 rows + 0 drafts on - /// page 0 — but that pattern triggered a GtkListBase bounds - /// invariant violation when the user then inserted a draft and - /// the stack crossfaded back to "grid": the GtkColumnView's - /// adjustments hadn't been allocated yet because the view was - /// the hidden stack child during the empty interlude, and the - /// first scroll / select call against it aborted with - /// `gtk_list_base_update_adjustments: bounds.y == 0`. - /// - /// GNOME Files / Builder don't show a status page for empty - /// lists either — they just render an empty list with column - /// headers. Mirror that: once the grid is built, stay on it - /// regardless of row count. The "Press Ctrl+N to add the first - /// row" hint moves into the column view's empty body (handled - /// natively by GtkColumnView's empty-area rendering). - fn refresh_inner_stack_visibility(&self) { - if self.current_result.is_some() { - self.inner_stack.set_visible_child_name("grid"); - } - } - - /// Walk `tracker.drafts()` and prepend each as a draft `RowObject` - /// at the top of the grid's `ListStore`. Forward iteration with - /// `insert(0, …)` preserves the original insertion order: newest - /// at the top, then older drafts beneath, then persisted rows. - fn reprepend_drafts(&self) { - let Some(store) = self.list_store() else { - return; - }; - for draft in self.tracker.drafts() { - store.insert( - 0, - &super::row_object::RowObject::new_draft(draft.draft_id, draft.values), - ); - } - } - - /// Force a single-row re-bind. Used when rejecting an invalid - /// cell edit: the `CellEditor` still holds the user's typed text - /// after editing-notify fires, so we trigger a re-bind to restore - /// the canonical display from `RowObject.cell_value()` (which we - /// did NOT mutate because the parse failed). - fn refresh_row(&self, position: u32) { - self.refresh_row_at(position); - } - - /// Build the (RowKey, current_values) pair for a row at the - /// given position. `row_position` is in selection-model space - /// (the space `GtkColumnView` activates against), which includes - /// prepended drafts — so we resolve through the selection model - /// rather than indexing `result.rows` directly. The earlier - /// direct-indexing version was off by N when N drafts were - /// prepended, silently targeting the wrong persisted row's PK - /// from the right-click delete, Set NULL shortcut, and any - /// cell-edit on a persisted row that sits below a draft. - /// - /// Returns None for drafts (caller is expected to handle the - /// draft path before reaching here), for tables with no PK, or - /// when the position is out of range. - fn row_key_at(&self, row_position: u32) -> Option<(RowKey, Vec)> { - let row_obj = self.row_object_at(row_position)?; - if row_obj.draft_id().is_some() { - return None; - } - let pk_indices: Vec = self - .current_columns - .iter() - .enumerate() - .filter(|(_, c)| c.primary_key) - .map(|(i, _)| i) - .collect(); - build_persisted_row_key(row_obj.cells_clone(), &pk_indices) - } - - /// Returns true when the loaded columns include at least one PK. - /// Used to gate Insert / Delete and reveal the no-PK banner. - fn has_primary_key(&self) -> bool { - self.current_columns.iter().any(|c| c.primary_key) - } - - fn refresh_crud_buttons(&self) { - let has_columns = !self.current_columns.is_empty(); - let has_pk = self.has_primary_key(); - if self.read_only { - // The Insert button lives in the per-table HeaderBar, not - // in this tab's own widget tree — but the visibility flip - // still drives the GtkWidget directly, so the header-bar - // slot just collapses when the connection is read-only. - self.insert_button.set_visible(false); - return; - } - self.insert_button.set_visible(true); - // No-PK tables don't get inline editing because RowKey can't be - // formed without a PK and our materialise path would silently - // no-op on UPDATE/DELETE. Disable instead of hide so the - // affordance stays discoverable; tooltip explains the gate. - self.insert_button.set_sensitive(has_columns && has_pk); - if has_columns && !has_pk { - self.insert_button.set_tooltip_text(Some(&crate::i18n::gettext( - "This table has no primary key. Inline editing is disabled.", - ))); - } else { - self.insert_button - .set_tooltip_text(Some(&crate::i18n::gettext("Insert row (Ctrl+N)"))); - } - self.refresh_banner_visibility(); - } - - /// Update the Filter button's count badge + tooltip based on the - /// current FilterSet. Active filters reveal a small numeric badge - /// next to the funnel icon and a count-aware tooltip; empty hides - /// the badge and falls back to the generic shortcut hint. Called - /// from FilterApplied + once on init so a restored filter shows - /// immediately. - fn refresh_filter_chrome(&self) { - let n = self.current_filter.len(); - if n == 0 { - self.filter_badge.set_visible(false); - self.filter_badge.set_label(""); - self.filter_button - .set_tooltip_text(Some(&crate::i18n::gettext("Filter rows (Ctrl+F)"))); - } else { - self.filter_badge.set_label(&n.to_string()); - self.filter_badge.set_visible(true); - self.filter_button.set_tooltip_text(Some(&crate::i18n::ngettext_f( - "{n} filter rule active. Click to edit", - "{n} filter rules active. Click to edit", - n as u32, - &[("n", &n.to_string())], - ))); - } - } - - fn update_paginator_label(&self) { - let Some(result) = self.current_result.as_ref() else { - self.paginator_label.set_label(""); - return; - }; - let n_rows = result.rows.len(); - if n_rows == 0 { - // Reachable when the user navigated past the end of a - // table that shrank in another session, before - // RowCountLoaded clamps the offset back. Human - // wording — the previous "No rows at offset N" read as - // a bug message. - self.paginator_label - .set_label(&crate::i18n::gettext("No rows on this page")); - return; - } - let start = self.current_offset + 1; - let end = self.current_offset + n_rows as u64; - // Match the page-size dropdown's thousands grouping. - // "Rows 10,001 – 10,100 of 5,000,000" is faster to read than - // "Rows 10001 – 10100 of 5000000" and matches GNOME File's - // "1,234 items" idiom. - let start_s = format_thousands(start); - let end_s = format_thousands(end); - let label = match self.current_total_rows { - Some(total) => crate::i18n::gettext_f( - "Rows {start} – {end} of {total}", - &[ - ("start", &start_s), - ("end", &end_s), - ("total", &format_thousands(total)), - ], - ), - None => crate::i18n::gettext_f("Rows {start} – {end}", &[("start", &start_s), ("end", &end_s)]), - }; - self.paginator_label.set_label(&label); - } - - fn replace_status_child(&self, name: &str, child: &impl IsA) { - if let Some(prev) = self.inner_stack.child_by_name(name) { - self.inner_stack.remove(&prev); - } - self.inner_stack.add_named(child, Some(name)); - self.inner_stack.set_visible_child_name(name); - } - - fn show_loading_inner(&self, title: &str, description: &str) { - // adw::Spinner replaces deprecated gtk::Spinner (GTK 4.12+). - let spinner = adw::Spinner::builder() - .width_request(32) - .height_request(32) - .halign(gtk::Align::Center) - .build(); - let page = adw::StatusPage::builder() - .title(title) - .description(description) - .child(&spinner) - .build(); - self.replace_status_child("loading", &page); - } - - fn show_error_inner(&self, message: &str) { - // Title pattern matches the structure tab ("Couldn't load - // structure"). The previous terse "Failed" left the user - // guessing what failed. - let page = adw::StatusPage::builder() - .icon_name(crate::ui::icons::DIALOG_ERROR) - .title(crate::i18n::gettext("Couldn't load rows")) - .description(message) - .build(); - self.replace_status_child("error", &page); - } -} - -impl SimpleComponent for BrowseTab { - type Init = BrowseTabInit; - type Input = BrowseTabInput; - type Output = BrowseTabOutput; - type Root = adw::ToolbarView; - type Widgets = (); - - fn init_root() -> Self::Root { - let root = adw::ToolbarView::new(); - // BrowseTab attaches directly to the workspace `AdwTabView` - // now (no outer wrapper / view switcher), so the default - // "raised" top-bar style draws a 1px separator above the grid - // even when every top bar is collapsed (banners + filter - // strip all start `revealed=false`). Flat style drops the - // separator + the slot padding — the grid butts cleanly - // against the bottom of the AdwTabBar. - root.set_top_bar_style(adw::ToolbarStyle::Flat); - root - } - - fn init(init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { - // Restore the saved filter for this (connection, schema, - // table) up front so both the model field and the inline - // strip start with the same FilterSet. - let initial_filter = init - .connection_id - .map(|id| init.filter_settings.filters(id, init.schema.as_deref(), &init.table)) - .unwrap_or_default(); - - let suppress_combo_emit = Rc::new(std::cell::Cell::new(true)); - let grid_holder = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .vexpand(true) - .build(); - let inner_stack = gtk::Stack::builder() - .transition_type(gtk::StackTransitionType::Crossfade) - .build(); - inner_stack.add_named(&grid_holder, Some("grid")); - // Initial state: loading. The first RowsLoaded swaps to "grid". - let initial_loading = adw::StatusPage::builder() - .title(crate::i18n::gettext("Loading…")) - .description(crate::i18n::gettext_f( - "Fetching rows from {table}", - &[( - "table", - &match init.schema.as_deref() { - Some(s) => format!("{s}.{}", init.table), - None => init.table.clone(), - }, - )], - )) - .child( - &adw::Spinner::builder() - .width_request(32) - .height_request(32) - .halign(gtk::Align::Center) - .build(), - ) - .build(); - inner_stack.add_named(&initial_loading, Some("loading")); - inner_stack.set_visible_child_name("loading"); - - let paginator = Self::build_paginator(sender.clone(), init.page_size); - let pending = Self::build_pending_revealer(sender.clone()); - - // Per-HIG banner rule: banners persist hard constraints the - // user can't fix by saving. Both reveal only when their - // condition triggers: - // - read-only: connection-wide constraint (most permanent). - // - no-PK: table-level constraint (per browse tab, persists - // until the user opens a different table). - // Pending-changes state lives in the tab-title bullet plus the - // ActionBar footer ("N unsaved changes / Discard / Save") — - // an additional banner would just duplicate that signal. - let read_only_banner = adw::Banner::builder() - .title(crate::i18n::gettext("Read-only connection. Editing disabled.")) - .revealed(init.read_only) - .build(); - let no_pk_banner = adw::Banner::builder() - .title(crate::i18n::gettext( - "This table has no primary key. Use the SQL editor to modify rows.", - )) - .revealed(false) - .build(); - - // Filter strip — inline editor that slides down above the - // grid when revealed. Ownership stays inside this BrowseTab - // so the user's in-progress rule edits survive a click into - // a cell or the SQL editor. - let filter_set_for_strip = initial_filter.clone(); - let sender_for_strip = sender.clone(); - let on_apply_filter: std::rc::Rc = std::rc::Rc::new(move |set| { - sender_for_strip.input(BrowseTabInput::FilterApplied(set)); - }); - let filter_strip = crate::ui::filter_strip::build(Vec::new(), filter_set_for_strip, on_apply_filter); - - // Banners + filter strip live in `AdwToolbarView::add_top_bar` - // (the libadwaita-canonical placement). The default top-bar - // slot allocates a thin strip even when every child banner is - // collapsed; toggling `reveal-top-bars` on the slot itself - // (based on whether ANY child is currently revealed) is the - // idiomatic way to collapse the slot to 0px. The closure - // re-runs on every banner / filter-strip reveal change. - root.add_top_bar(&read_only_banner); - root.add_top_bar(&no_pk_banner); - root.add_top_bar(&filter_strip.widget); - root.set_content(Some(&inner_stack)); - - // The banners and the strip hold this through their notify - // handlers, and the root holds them, so it holds all four weakly. - let sync_top_bar_slot: std::rc::Rc = { - let root_for_sync = root.downgrade(); - let read_only_for_sync = read_only_banner.downgrade(); - let no_pk_for_sync = no_pk_banner.downgrade(); - let filter_for_sync = filter_strip.widget.downgrade(); - std::rc::Rc::new(move || { - let (Some(root), Some(read_only), Some(no_pk), Some(filter)) = ( - root_for_sync.upgrade(), - read_only_for_sync.upgrade(), - no_pk_for_sync.upgrade(), - filter_for_sync.upgrade(), - ) else { - return; - }; - root.set_reveal_top_bars(read_only.is_revealed() || no_pk.is_revealed() || filter.reveals_child()); - }) - }; - sync_top_bar_slot(); - { - let sync = sync_top_bar_slot.clone(); - read_only_banner.connect_revealed_notify(move |_| sync()); - } - { - let sync = sync_top_bar_slot.clone(); - no_pk_banner.connect_revealed_notify(move |_| sync()); - } - { - let sync = sync_top_bar_slot.clone(); - filter_strip.widget.connect_reveal_child_notify(move |_| sync()); - } - // Bottom toolbars (stacked in `add_bottom_bar` call order): - // 1. Paginator — always visible (nav + count + page size + - // Filter + Export). - // 2. Pending revealer — slides into view only when the tab - // has unsaved edits (Save / Discard / "N unsaved" label). - // Visual hierarchy: grid → paginator → pending (transient). - root.add_bottom_bar(&paginator.bar); - root.add_bottom_bar(&pending.widget); - // Per-tab GridMsg channel: events from this tab's grid (sort - // change, cell edits, context-menu actions) flow into this tab's - // own input queue, which then re-emits them as outputs to App - // tagged with this tab's id (via the forward closure App sets up). - // Created up-front so tab-local shortcuts (Ctrl+Shift+N → set - // focused cell to NULL) can route directly to the grid sender. - let (grid_sender, grid_receiver) = relm4::channel::(); - - let sender_for_esc = sender.clone(); - let esc_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::Escape, - gtk::gdk::ModifierType::empty(), - )) - .action(>k::CallbackAction::new(move |_, _| { - // Esc on the grid (no edit in progress) clears a - // multi-row selection. Spreadsheet convention - // (Excel / LibreOffice / DataGrip): Esc cancels - // the in-progress selection without deleting - // anything. Single-row selections fall through - // because GtkColumnView treats single-select as - // "the focused row" and unselecting it would - // strand the focus indicator. Cell-edit Esc fires - // first via the editor's capture-phase handler - // and never reaches us. - sender_for_esc.input(BrowseTabInput::ClearSelection); - glib::Propagation::Proceed - })) - .build(); - - // Tab-local shortcuts for browse-grid keyboard model. Local - // scope means these only fire while focus is inside this - // BrowseTab. When the user is in another tab or in the editor, - // these triggers fall through to whatever that context wires. - // - // - Delete: mark selected rows for pending deletion (matches - // the Delete-button toolbar action; HIG keyboard reference - // "Delete = Delete the selected item"). - // - Ctrl+N: insert a draft row (HIG "Ctrl+N = Create a new - // document"; document = row in this context). - // - Ctrl+Shift+N: set the focused cell to SQL NULL. App- - // specific binding (no GNOME precedent for "set NULL"); - // chosen because Ctrl+Backspace conflicts with delete-word - // in every text-edit widget. - let sender_for_delete = sender.clone(); - let delete_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::Delete, - gtk::gdk::ModifierType::empty(), - )) - .action(>k::CallbackAction::new(move |_, _| { - sender_for_delete.input(BrowseTabInput::DeleteSelectedRow); - glib::Propagation::Stop - })) - .build(); - let sender_for_insert = sender.clone(); - let insert_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::n, - gtk::gdk::ModifierType::CONTROL_MASK, - )) - .action(>k::CallbackAction::new(move |_, _| { - sender_for_insert.input(BrowseTabInput::InsertRow); - glib::Propagation::Stop - })) - .build(); - let grid_sender_for_null = grid_sender.clone(); - let null_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::n, - gtk::gdk::ModifierType::CONTROL_MASK | gtk::gdk::ModifierType::SHIFT_MASK, - )) - .action(>k::CallbackAction::new(move |widget, _| { - let Some((row_position, col_index)) = super::grid::focused_cell_coords(widget) else { - return glib::Propagation::Proceed; - }; - grid_sender_for_null - .send(GridMsg::SetCellValue { - row_position, - col_index, - preset: CellPreset::Null, - }) - .ok(); - glib::Propagation::Stop - })) - .build(); - - // Ctrl+C: when row(s) are selected and focus is NOT inside a - // text-editor (cell edit mode, search entry, draft input), copy - // the selection as TSV. Bubble-phase Local scope: GtkText - // consumes Ctrl+C while editing so the cell-text-selection - // copy still works without our handler interfering. - let sender_for_copy = sender.clone(); - let copy_rows_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::c, - gtk::gdk::ModifierType::CONTROL_MASK, - )) - .action(>k::CallbackAction::new(move |_, _| { - sender_for_copy.input(BrowseTabInput::CopySelectedRowsAsTsv); - glib::Propagation::Stop - })) - .build(); - // Ctrl+V on the grid (focus not inside a text editor): show a - // toast explaining multi-row paste isn't supported. Cell-level - // paste (focus inside a CellEditor's GtkText) is consumed - // by the entry first, so this only fires for grid-level paste - // attempts. - let sender_for_paste = sender.clone(); - let paste_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::v, - gtk::gdk::ModifierType::CONTROL_MASK, - )) - .action(>k::CallbackAction::new(move |_, _| { - sender_for_paste.input(BrowseTabInput::PasteNotSupported); - glib::Propagation::Stop - })) - .build(); - // Ctrl+A: select every visible row. Standard "select all" - // affordance — gives the user a quick path into bulk-delete - // (which then triggers the M3 confirmation dialog when the - // count exceeds the threshold). - let sender_for_select_all = sender.clone(); - let select_all_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::a, - gtk::gdk::ModifierType::CONTROL_MASK, - )) - .action(>k::CallbackAction::new(move |_, _| { - sender_for_select_all.input(BrowseTabInput::SelectAllRows); - glib::Propagation::Stop - })) - .build(); - // Page Up / Page Down on the BrowseTab navigate the paginator. - // GtkColumnView's built-in scrolling normally handles these, - // but our offset-based pagination means scrolling stops at - // the page boundary. Mapping PgUp/PgDn to Prev/Next page - // keeps the keyboard fluent across pages. - let sender_for_pgup = sender.clone(); - let page_up_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::Page_Up, - gtk::gdk::ModifierType::empty(), - )) - .action(>k::CallbackAction::new(move |_, _| { - sender_for_pgup.input(BrowseTabInput::PrevPage); - glib::Propagation::Stop - })) - .build(); - let sender_for_pgdn = sender.clone(); - let page_down_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::Page_Down, - gtk::gdk::ModifierType::empty(), - )) - .action(>k::CallbackAction::new(move |_, _| { - sender_for_pgdn.input(BrowseTabInput::NextPage); - glib::Propagation::Stop - })) - .build(); - // Home / End scoped to row navigation: jump to the first / - // last visible row of the current page. - let sender_for_home = sender.clone(); - let home_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::Home, - gtk::gdk::ModifierType::CONTROL_MASK, - )) - .action(>k::CallbackAction::new(move |_, _| { - sender_for_home.input(BrowseTabInput::GoToFirstRow); - glib::Propagation::Stop - })) - .build(); - let sender_for_end = sender.clone(); - let end_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::End, - gtk::gdk::ModifierType::CONTROL_MASK, - )) - .action(>k::CallbackAction::new(move |_, _| { - sender_for_end.input(BrowseTabInput::GoToLastRow); - glib::Propagation::Stop - })) - .build(); - - let esc_controller = gtk::ShortcutController::new(); - esc_controller.set_scope(gtk::ShortcutScope::Local); - esc_controller.add_shortcut(esc_shortcut); - esc_controller.add_shortcut(delete_shortcut); - esc_controller.add_shortcut(insert_shortcut); - esc_controller.add_shortcut(null_shortcut); - esc_controller.add_shortcut(copy_rows_shortcut); - esc_controller.add_shortcut(paste_shortcut); - esc_controller.add_shortcut(select_all_shortcut); - esc_controller.add_shortcut(page_up_shortcut); - esc_controller.add_shortcut(page_down_shortcut); - esc_controller.add_shortcut(home_shortcut); - esc_controller.add_shortcut(end_shortcut); - root.add_controller(esc_controller); - - // Wire the GridMsg receiver into this tab's input queue. Each - // GridMsg becomes a BrowseTabInput tagged with the same payload - // shape; the tab's update() then routes them to the App via - // outputs that App's forwarder tags with this tab's id. - let grid_input = sender.input_sender().clone(); - glib::spawn_future_local(grid_receiver.forward(grid_input, |msg| match msg { - GridMsg::SortChanged(col_idx, ascending) => BrowseTabInput::SortChanged { col_idx, ascending }, - GridMsg::CellEdited { - row_position, - col_index, - new_value, - } => BrowseTabInput::GridCellEdited { - row_position, - col_index, - new_value, - }, - GridMsg::CopyToClipboard(text) => BrowseTabInput::GridCopyToClipboard(text), - GridMsg::ShowToast(text) => BrowseTabInput::GridShowToast(text), - GridMsg::CopyRowAsInsert { row_position } => BrowseTabInput::GridCopyRowAsInsert { row_position }, - GridMsg::SetCellValue { - row_position, - col_index, - preset, - } => BrowseTabInput::GridSetCellValue { - row_position, - col_index, - preset, - }, - GridMsg::ExportResults(result) => BrowseTabInput::GridExportResults(result), - GridMsg::DeleteRowAt { row_position } => BrowseTabInput::GridDeleteRowAt { row_position }, - GridMsg::InsertRow => BrowseTabInput::InsertRow, - GridMsg::DuplicateRow { row_position } => BrowseTabInput::DuplicateRow { row_position }, - })); - - let model = BrowseTab { - tracker: init.tracker, - schema: init.schema, - table: init.table, - connection_id: init.connection_id, - read_only: init.read_only, - column_widths: init.column_widths, - filter_settings: init.filter_settings, - current_offset: init.initial_offset, - page_size: init.page_size, - current_sort: init.initial_sort, - current_filter: initial_filter, - current_columns: Vec::new(), - current_result: None, - current_selection: None, - current_total_rows: None, - inner_stack, - grid_holder, - current_column_view: None, - rendered_column_count: std::cell::Cell::new(0), - read_only_banner, - no_pk_banner, - was_dirty: std::cell::Cell::new(false), - pending_focus_restore: std::cell::RefCell::new(None), - paginator_label: paginator.paginator_label, - selection_label: paginator.selection_label, - first_button: paginator.first_button, - prev_button: paginator.prev_button, - next_button: paginator.next_button, - last_button: paginator.last_button, - filter_button: paginator.filter_button, - filter_badge: paginator.filter_badge, - filter_strip: Some(filter_strip), - insert_button: paginator.insert_button, - pending_revealer: pending.widget, - save_button: pending.save_button, - discard_button: pending.discard_button, - pending_label: pending.pending_label, - grid_sender, - suppress_combo_emit, - }; - model.refresh_crud_buttons(); - model.refresh_pending_bar(0); - // If the user has a saved filter on this (connection, schema, - // table), the button picks up the .accent badge before the - // first fetch returns so the filter state is visible from - // the moment the tab opens. - model.refresh_filter_chrome(); - - // Trigger the initial fetches the moment the parent attaches us. - // The parent's forward closure wraps these in AppMsg::… with tab_id. - let _ = sender.output(BrowseTabOutput::FetchColumns); - let _ = sender.output(BrowseTabOutput::FetchRowCount); - let _ = sender.output(BrowseTabOutput::FetchPage); - ComponentParts { model, widgets: () } - } - - fn update(&mut self, msg: Self::Input, sender: ComponentSender) { - match msg { - BrowseTabInput::RowsLoaded { offset, result } => { - self.current_offset = offset; - // Driver fallback: every shipping driver derives the - // `QueryResult.columns` list from the FIRST returned - // row, so a zero-row page comes back with an empty - // columns vector. The grid factory iterates that vector - // to build column-view columns, which left the empty - // table rendering as a column-less dark rectangle. - // information_schema (via ColumnsLoaded) already gave - // us the authoritative column list — substitute it in - // so the headers render even when the page is empty. - let mut result = result; - if result.columns.is_empty() && !self.current_columns.is_empty() { - result.columns = result_columns(&self.current_columns); - } - self.current_result = Some(result); - // Defer rendering until columns are also loaded — the - // QueryResult's ColumnInfo lacks `primary_key` / - // `is_generated` / `is_auto_increment`, so rendering - // before the schema fetch would let the user edit cells - // (PK, generated columns) that the DB will reject on - // save. Waiting also avoids a wasted full rebuild when - // ColumnsLoaded fires next and triggers a re-render. - self.render_grid_if_ready(sender); - } - BrowseTabInput::ColumnsLoaded(columns) => { - let words: Vec = columns.iter().map(|c| c.name.clone()).collect(); - self.current_columns = columns.clone(); - // Late-arriving columns: if RowsLoaded already cached a - // zero-row result with an empty `columns` vector (the - // driver derives it from the first row), refill it now - // so the upcoming `render_grid_if_ready` builds headers - // against the real schema instead of an empty list. - if let Some(result) = self.current_result.as_mut() - && result.columns.is_empty() - { - result.columns = result_columns(&columns); - } - self.refresh_crud_buttons(); - // Filter strip rebuilds against the new schema — - // operator allowlists narrow per type, so a column - // that switched from text to int needs its operator - // dropdown refreshed. - if let Some(strip) = self.filter_strip.as_ref() { - strip.update_columns(columns); - } - let _ = sender.output(BrowseTabOutput::SchemaWordsChanged(words)); - // If rows are already cached, render now with the proper - // editability map. Otherwise wait for RowsLoaded. - self.render_grid_if_ready(sender); - } - BrowseTabInput::RowCountLoaded(count) => { - self.current_total_rows = Some(count); - // If the saved offset is now past the end, clamp it back to - // the last full page and refetch — guards against stale - // persistence after rows were deleted in another session. - if self.current_offset > 0 && count > 0 && self.current_offset >= count { - let last_page_offset = count.saturating_sub(1) / self.page_size * self.page_size; - if last_page_offset != self.current_offset { - self.current_offset = last_page_offset; - let _ = sender.output(BrowseTabOutput::FetchPage); - let _ = sender.output(BrowseTabOutput::StateChanged); - } - } - self.update_paginator_label(); - } - BrowseTabInput::ShowError(message) => { - // Clear any cached page state so a follow-up refresh - // doesn't render against the stale snapshot before - // RowsLoaded arrives. Paginator label is left empty - // until next RowCountLoaded. - self.current_result = None; - self.current_total_rows = None; - self.paginator_label.set_label(""); - self.first_button.set_sensitive(false); - self.prev_button.set_sensitive(false); - self.next_button.set_sensitive(false); - self.last_button.set_sensitive(false); - self.show_error_inner(&message); - self.inner_stack.set_visible_child_name("error"); - } - BrowseTabInput::Refresh => self.reload(&sender), - BrowseTabInput::DiscardAndRefresh => { - let delta = self.tracker.discard(); - self.apply_tracker_delta(delta, &sender); - self.reload(&sender); - } - BrowseTabInput::ClearSelection => { - let Some(sel) = self.current_selection.as_ref() else { - return; - }; - // Only clear when we have a true multi-row selection. - // Without this guard, every Esc on a single-focus row - // would re-trigger the "0 selected" path through GTK's - // re-focus logic and strand the focus indicator. - if sel.selection().size() < 2 { - return; - } - sel.unselect_all(); - } - BrowseTabInput::ToggleFilterStrip => { - if let Some(strip) = self.filter_strip.as_ref() { - strip.toggle(); - } - } - BrowseTabInput::FilterApplied(set) => { - // No change to the rule list → don't churn the disk - // or refetch. Re-fetch on identical filter would just - // duplicate the F5 path, which the user can take - // explicitly. - if set == self.current_filter { - return; - } - self.current_filter = set.clone(); - if let Some(conn_id) = self.connection_id { - self.filter_settings - .set_filters(conn_id, self.schema.as_deref(), &self.table, set.clone()); - } - // Filtered counts shift; jump back to page 1 so the - // user isn't stranded on offset N where N might be - // beyond the new filtered total. - self.current_offset = 0; - self.refresh_filter_chrome(); - if let Some(strip) = self.filter_strip.as_ref() { - strip.update_filter(set); - } - let _ = sender.output(BrowseTabOutput::FetchPage); - let _ = sender.output(BrowseTabOutput::FetchRowCount); - let _ = sender.output(BrowseTabOutput::StateChanged); - } - BrowseTabInput::FirstPage => { - if self.current_offset > 0 { - self.current_offset = 0; - let _ = sender.output(BrowseTabOutput::FetchPage); - let _ = sender.output(BrowseTabOutput::StateChanged); - } - } - BrowseTabInput::PrevPage => { - if self.current_offset >= self.page_size { - self.current_offset -= self.page_size; - let _ = sender.output(BrowseTabOutput::FetchPage); - let _ = sender.output(BrowseTabOutput::StateChanged); - } - } - BrowseTabInput::NextPage => { - self.current_offset += self.page_size; - let _ = sender.output(BrowseTabOutput::FetchPage); - let _ = sender.output(BrowseTabOutput::StateChanged); - } - BrowseTabInput::LastPage => { - let Some(total) = self.current_total_rows else { - // Total unknown — Last has no target. UI keeps the - // button disabled until RowCountLoaded fires, so - // this branch is a defensive guard. - return; - }; - if total == 0 { - return; - } - let last_page_offset = (total - 1) / self.page_size * self.page_size; - if self.current_offset != last_page_offset { - self.current_offset = last_page_offset; - let _ = sender.output(BrowseTabOutput::FetchPage); - let _ = sender.output(BrowseTabOutput::StateChanged); - } - } - BrowseTabInput::SortChanged { col_idx, ascending } => { - // Idempotent: GtkColumnViewSorter fires both - // `primary-sort-column` and `primary-sort-order` - // notifies for one logical click on a different - // column (column changes; order resets). Each - // notify dispatches the same post-state pair, so - // we short-circuit when the pair already matches. - let next = (col_idx, ascending); - if self.current_sort == Some(next) { - return; - } - self.current_sort = Some(next); - self.current_offset = 0; - self.capture_focus_for_restore(); - let _ = sender.output(BrowseTabOutput::FetchPage); - let _ = sender.output(BrowseTabOutput::StateChanged); - } - BrowseTabInput::PageSizeChanged(size) => { - if self.suppress_combo_emit.get() || self.page_size == size { - return; - } - self.page_size = size; - self.current_offset = 0; - let _ = sender.output(BrowseTabOutput::FetchPage); - let _ = sender.output(BrowseTabOutput::StateChanged); - } - BrowseTabInput::DuplicateRow { row_position } => { - if self.current_columns.is_empty() { - return; - } - // Read the source row through the live RowObject at - // `row_position` — NOT by indexing - // `current_result.rows` directly. The grid's - // row_position reflects the user's current sort and - // any prepended drafts; raw `rows` is fetch order. A - // sort would otherwise hand us the wrong row's cells. - let Some(source) = self.row_object_at(row_position) else { - return; - }; - let source_cells = source.cells_clone(); - // Clone source values; blank columns whose value is - // owned by the database (PK, identity / serial, - // generated). The duplicate is meant to be a *new* - // row — inheriting the source's identity would either - // collide on save or pre-fill nonsense. - let values: Vec = self - .current_columns - .iter() - .enumerate() - .map(|(i, col)| { - if col.primary_key || col.is_auto_increment || col.is_generated { - Value::Null - } else { - source_cells.get(i).cloned().unwrap_or(Value::Null) - } - }) - .collect(); - self.apply_edit(TrackerEdit::Insert { values }, &sender); - self.refresh_inner_stack_visibility(); - // Scroll + focus deferred via FocusInsertedDraft — see - // the matching note in the `InsertRow` arm above. Same - // `bounds.y == 0` assertion fires if `cv.scroll_to` - // lands in the same tick as a stack flip. - sender.input(BrowseTabInput::FocusInsertedDraft); - } - BrowseTabInput::InsertRow => { - if self.current_columns.is_empty() { - return; - } - let values: Vec = self.current_columns.iter().map(|_| Value::Null).collect(); - self.apply_edit(TrackerEdit::Insert { values }, &sender); - // The empty-state status page hides the column view — - // if we were sitting on it (zero persisted rows on the - // first page) the draft we just appended would be - // invisible. Re-derive the inner stack visibility now - // that there's a draft to show. - self.refresh_inner_stack_visibility(); - // Scroll + focus are deferred via FocusInsertedDraft. - // Calling `cv.scroll_to(...)` synchronously here used - // to crash with - // Gtk-ERROR gtk_list_base_update_adjustments: - // assertion failed: (bounds.y == 0) - // when the empty → grid flip and the scroll landed in - // the same tick: the ColumnView's ScrolledWindow had - // no realized adjustments to update. FocusInsertedDraft - // runs from `glib::idle_add_local_once`, after GTK has - // finished allocating the now-visible grid, so the - // adjustments are valid by then. - sender.input(BrowseTabInput::FocusInsertedDraft); - } - BrowseTabInput::DeleteSelectedRow => { - // Toolbar Delete now marks the selected rows for - // pending deletion (red strikethrough via tracker - // overlay). User reviews + clicks Save to commit, or - // Discard / Ctrl+Z to revert. Replaces the previous - // confirm-dialog-then-immediate-DELETE flow. - let Some(selection) = self.current_selection.as_ref() else { - return; - }; - let positions = super::grid::selected_positions(selection); - if positions.is_empty() { - return; - } - let pk_indices: Vec = self - .current_columns - .iter() - .enumerate() - .filter(|(_, c)| c.primary_key) - .map(|(i, _)| i) - .collect(); - // Partition the selection into persisted rows (need a - // PK to build a RowKey) and drafts (in-memory only, - // discarded by id). We resolve through the selection - // model rather than `result.rows[pos]` directly - // because `pos` is in selection-model space, which - // includes prepended drafts. A pure-draft selection - // is valid (the user can bulk-discard pending - // inserts before saving). - let Some(model) = selection.model() else { - return; - }; - let mut snapshot: Vec<(RowKey, Vec)> = Vec::new(); - let mut draft_ids: Vec = Vec::new(); - let mut had_persisted_row = false; - for pos in &positions { - let Some(item) = model.item(*pos) else { continue }; - let Ok(row_obj) = item.downcast::() else { - continue; - }; - if let Some(draft_id) = row_obj.draft_id() { - draft_ids.push(draft_id); - continue; - } - had_persisted_row = true; - let cells = row_obj.cells_clone(); - if let Some(pair) = build_persisted_row_key(cells, &pk_indices) { - snapshot.push(pair); - } - } - // PK gate only fires when persisted rows are involved. - // Pure-draft selections sail through to discard. - if had_persisted_row && pk_indices.is_empty() { - let _ = sender.output(BrowseTabOutput::ShowSelectionAlert { - title: crate::i18n::gettext("Cannot delete"), - body: crate::i18n::gettext("This table has no primary key, so editing is disabled."), - }); - return; - } - if snapshot.is_empty() && draft_ids.is_empty() { - return; - } - let count = snapshot.len() + draft_ids.len(); - let sender_for_commit = sender.clone(); - let commit_delete = move |rows: Vec<(RowKey, Vec)>, drafts: Vec| { - sender_for_commit.input(BrowseTabInput::MarkRowsDeleted { rows, drafts }); - }; - if count >= BULK_DELETE_CONFIRM_THRESHOLD { - // Dialog parent is any descendant of the toplevel - // window — adw::AlertDialog walks up to find the - // window. The inner_stack is always parented to - // the BrowseTab's root toolbar, so it resolves - // correctly while the tab is visible. - let title = crate::i18n::ngettext_f( - "Delete {n} row?", - "Delete {n} rows?", - count as u32, - &[("n", &count.to_string())], - ); - let body = crate::i18n::gettext( - "These rows will be marked for deletion. They aren't removed until you click Save.", - ); - let dialog = adw::AlertDialog::new(Some(&title), Some(&body)); - dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); - dialog.add_response("delete", &crate::i18n::gettext("Delete")); - dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - let pending = std::cell::RefCell::new(Some((snapshot, draft_ids))); - dialog.connect_response(None, move |dlg, response| { - dlg.close(); - if response == "delete" - && let Some((s, d)) = pending.borrow_mut().take() - { - commit_delete(s, d); - } - }); - dialog.present(Some(&self.inner_stack)); - } else { - commit_delete(snapshot, draft_ids); - } - } - BrowseTabInput::GridCellEdited { - row_position, - col_index, - new_value, - } => { - // Cell edits route through the per-tab change tracker - // so the user can review / Save / Discard a batch. - // - // Empty input on a nullable column becomes Value::Null - // (canonical SQL convention for "user cleared the - // cell"). Non-empty input is parsed against the - // column's data_type so every native type binds at - // its declared kind instead of falling back to text. - // - // On parse failure the edit is rejected: a toast - // explains why and `refresh_row` forces a re-bind so - // the cell goes back to the canonical pre-edit - // display. The tracker stays untouched so a single - // bad keystroke can't sneak into the batch. - // - // GtkText handles multi-line clipboard paste - // inconsistently across GTK builds — some embed - // literal newlines, some strip them silently. - // Collapse newlines / carriage returns to spaces - // here so a paste-induced multi-line value never - // reaches the SQL layer. JSON columns aren't - // normalised: they need real newlines. - // A column that holds line breaks keeps them; everywhere - // else a pasted newline would reach the SQL layer. - let keeps_line_breaks = self - .current_columns - .get(col_index) - .is_some_and(|c| c.column_type.kind.accepts_line_breaks()); - let normalized = match keeps_line_breaks { - true => new_value, - false => normalize_single_line_input(&new_value), - }; - let col = self.current_columns.get(col_index); - let new = match parse_input_for_column(&normalized, col, self.edit_context_at(row_position)) { - Ok(v) => v, - Err(message) => { - let _ = sender.output(BrowseTabOutput::ShowToast(message)); - self.refresh_row(row_position); - return; - } - }; - self.edit_cell(row_position, col_index, new, &sender); - } - BrowseTabInput::GridSetCellValue { - row_position, - col_index, - preset, - } => { - let value = match self.resolve_cell_preset(preset, col_index, self.edit_context_at(row_position)) { - Ok(value) => value, - Err(message) => { - let _ = sender.output(BrowseTabOutput::ShowToast(message)); - return; - } - }; - self.edit_cell(row_position, col_index, value, &sender); - } - BrowseTabInput::GridShowToast(message) => { - let _ = sender.output(BrowseTabOutput::ShowToast(message)); - } - BrowseTabInput::GridExportResults(mut result) => { - // The grid owns the rows it is showing; the fetch that - // produced them belongs to the tab, and the hot-path - // page refresh swaps rows under a ColumnView built for - // an earlier fetch. - result.truncated = self.current_result.as_ref().is_some_and(|r| r.truncated); - let _ = sender.output(BrowseTabOutput::ExportResults { - result, - name: self.export_name(), - target: Some(self.sql_target()), - }); - } - BrowseTabInput::ExportCurrentPage => { - let Some(result) = self.export_payload() else { - let _ = sender.output(BrowseTabOutput::ShowToast(crate::i18n::gettext("Nothing to export"))); - return; - }; - let _ = sender.output(BrowseTabOutput::ExportResults { - result, - name: self.export_name(), - target: Some(self.sql_target()), - }); - } - BrowseTabInput::GridDeleteRowAt { row_position } => { - let Some((row_key, original_values)) = self.row_key_at(row_position) else { - return; - }; - self.apply_edit( - TrackerEdit::Delete { - row_key, - original_values, - }, - &sender, - ); - } - BrowseTabInput::GridCopyRowAsInsert { row_position } => { - let _ = sender.output(BrowseTabOutput::CopyRowAsInsert { row_position }); - } - BrowseTabInput::GridCopyToClipboard(text) => { - let _ = sender.output(BrowseTabOutput::CopyToClipboard(text)); - } - BrowseTabInput::CopySelectedRowsAsTsv => { - // Same renderer as the context menu's Copy as > Rows: - // one selection cannot produce two different clipboard - // payloads depending on how the user asked for it. - let Some(selection) = self.current_selection.as_ref() else { - return; - }; - let positions = super::grid::selected_positions(selection); - if positions.is_empty() { - return; - } - let Some(model) = selection.model() else { - return; - }; - let ctx = self.grid_context(); - let rows: Vec> = positions - .iter() - .filter_map(|pos| model.item(*pos)) - .filter_map(|item| item.downcast::().ok()) - .map(|row| ctx.effective_cells(&row)) - .collect(); - if rows.is_empty() { - return; - } - match tablepro_core::export::render_tsv(&result_columns(&self.current_columns), &rows, false) { - Ok(tsv) => { - let _ = sender.output(BrowseTabOutput::CopyToClipboard(tsv)); - } - Err(error) => { - let _ = sender.output(BrowseTabOutput::ShowToast(crate::i18n::gettext_f( - "Couldn't copy the selection: {error}", - &[("error", &error.to_string())], - ))); - } - } - } - BrowseTabInput::PasteNotSupported => { - let _ = sender.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( - "Pasting rows isn't supported yet", - ))); - } - BrowseTabInput::SelectAllRows => { - if let Some(selection) = self.current_selection.as_ref() { - let n = selection.n_items(); - if n > 0 { - selection.select_all(); - } - } - } - BrowseTabInput::GoToFirstRow => { - let Some(cv) = self.current_column_view.as_ref() else { - return; - }; - let n = self.current_selection.as_ref().map(|s| s.n_items()).unwrap_or(0); - if n == 0 { - return; - } - cv.scroll_to( - 0, - None, - gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, - None, - ); - } - BrowseTabInput::GoToLastRow => { - let Some(cv) = self.current_column_view.as_ref() else { - return; - }; - let n = self.current_selection.as_ref().map(|s| s.n_items()).unwrap_or(0); - if n == 0 { - return; - } - cv.scroll_to( - n - 1, - None, - gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, - None, - ); - } - BrowseTabInput::CommitSave => { - let columns = self.current_columns.clone(); - match self.tracker.change_set(&columns) { - Ok(changes) if !changes.changes.is_empty() => { - // Disable both buttons for the duration of the - // in-flight transaction. SaveCompleted / - // SaveFailed re-enable them. This prevents a - // double-click firing two transactions and - // matches GNOME's standard "in-progress action" - // affordance (busy spinner + disabled control). - self.save_button.set_sensitive(false); - self.discard_button.set_sensitive(false); - // SaveCompleted will refetch the page; capture - // the focused row's PK now so it can be re- - // selected after the reload. - self.capture_focus_for_restore(); - let _ = sender.output(BrowseTabOutput::ExecuteTransaction { - table: tablepro_core::meta::TableRef::new(self.schema.clone(), self.table.clone()), - columns, - changes, - }); - } - Ok(_) => self.refresh_pending_bar(0), - Err(e) => { - let _ = sender.output(BrowseTabOutput::ShowSelectionAlert { - title: crate::i18n::gettext("Cannot save"), - body: format!("{e}"), - }); - } - } - } - BrowseTabInput::DiscardAll => { - let delta = self.tracker.discard(); - self.apply_tracker_delta(delta, &sender); - let _ = sender.output(BrowseTabOutput::FetchPage); - } - BrowseTabInput::MarkRowsDeleted { rows, drafts } => { - for (row_key, original_values) in rows { - self.apply_edit( - TrackerEdit::Delete { - row_key, - original_values, - }, - &sender, - ); - } - for draft_id in drafts { - self.apply_edit(TrackerEdit::DiscardDraft { draft_id }, &sender); - } - // A bulk action ends the selection it acted on. - if let Some(selection) = self.current_selection.as_ref() { - selection.unselect_all(); - } - } - BrowseTabInput::SaveCompleted => { - // The reload replaces every row; dropping the saved - // drafts from the store first would blank them until it - // lands. - let delta = self.tracker.discard(); - self.sync_pending_chrome(delta.pending_count, &sender); - self.save_button.set_sensitive(true); - self.discard_button.set_sensitive(true); - let _ = sender.output(BrowseTabOutput::FetchPage); - let _ = sender.output(BrowseTabOutput::FetchRowCount); - } - BrowseTabInput::SaveFailed(message) => { - self.save_button.set_sensitive(true); - self.discard_button.set_sensitive(true); - let _ = sender.output(BrowseTabOutput::ShowSelectionAlert { - title: crate::i18n::gettext("Save failed"), - body: message, - }); - } - BrowseTabInput::FlashErrorRow(source) => { - self.flash_error_row(&source); - } - BrowseTabInput::FocusInsertedDraft => { - self.focus_inserted_draft(); - } - BrowseTabInput::Undo => { - if let Some(delta) = self.tracker.undo() { - self.apply_tracker_delta(delta, &sender); - } - } - BrowseTabInput::Redo => { - if let Some(delta) = self.tracker.redo() { - self.apply_tracker_delta(delta, &sender); - } - } - } - } -} - -fn apply_visual_change(store: >k::gio::ListStore, change: &VisualChange) { - use super::row_object::RowObject; - let draft_at = |draft_id: u64| { - (0..store.n_items()).find_map(|position| { - let row = store.item(position).and_downcast::()?; - (row.draft_id() == Some(draft_id)).then_some((position, row)) - }) - }; - match change { - VisualChange::InsertDraft { draft_id, values } => { - store.insert(0, &RowObject::new_draft(*draft_id, values.clone())); - } - VisualChange::RemoveDraft { draft_id } => { - if let Some((position, _)) = draft_at(*draft_id) { - store.remove(position); - } - } - VisualChange::SetDraftCell { draft_id, col, value } => { - if let Some((_, row)) = draft_at(*draft_id) { - row.set_cell(*col, value.clone()); - } - } - } -} - -fn clear_box(b: >k::Box) { - while let Some(child) = b.first_child() { - b.remove(&child); - } -} - -/// Collapse newlines / carriage returns to spaces, then squash any -/// resulting consecutive whitespace runs to a single space. Applied -/// at cell-edit commit time for non-JSON columns so a multi-line -/// clipboard paste into a single-line cell never reaches the SQL -/// layer with embedded `\n` — driver behaviour for that case is -/// type-specific (text columns store literally; numeric / date -/// columns parse-fail) and worth normalising up front. -fn normalize_single_line_input(text: &str) -> String { - let replaced: String = text - .chars() - .map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }) - .collect(); - replaced.split_whitespace().collect::>().join(" ") -} - -/// Build a `(RowKey, cells)` pair for a persisted row given its full -/// cell slice and the table's PK column indices. Returns `None` when -/// pk_indices is empty (no PK), any index is out of range, or -/// `RowKey::from_pk_values` rejects the values. -/// -/// Pure function so it stays unit-testable without spinning up GTK -/// / RowObject. Callers feed it a clone of the row's cells (already -/// pulled from the model in selection-model space, see the -/// row-position-vs-result-rows note in `DeleteSelectedRow`). -fn build_persisted_row_key(cells: Vec, pk_indices: &[usize]) -> Option<(RowKey, Vec)> { - let pk_values: Vec = pk_indices - .iter() - .map(|&i| cells.get(i).cloned()) - .collect::>()?; - let key = RowKey::from_pk_values(&pk_values)?; - Some((key, cells)) -} - -/// Update the selection-count badge in response to a -/// `MultiSelection` change. Hidden when 0–1 rows are selected -/// (single-row state has no scaling text need); shows -/// "{n} selected · press Delete to remove" once the user -/// multi-selects so the affordance stays discoverable now that -/// the toolbar Delete button is gone (right-click + Delete key -/// are the action surface). -fn update_selection_chrome(label: >k::Label, n: u32) { - if n <= 1 { - label.set_visible(false); - return; - } - let count = n.to_string(); - label.set_label(&crate::i18n::gettext_f( - "{n} selected · press Delete to remove", - &[("n", &count)], - )); - label.set_visible(true); -} - -/// The value the user typed, as the column's own type. -/// -/// Parsing against the typed column rather than a type-name string is -/// what makes an out-of-range number a message beside the cell instead -/// of a failed transaction. What an empty cell means is core's rule: -/// the empty string where the column holds one, the server's default -/// in a draft row, NULL where the column takes it, and a refusal -/// otherwise. -fn parse_input_for_column(text: &str, col: Option<&ColumnInfo>, context: EditContext) -> Result { - let Some(col) = col else { - return Ok(Value::Text(text.to_string())); - }; - match tablepro_core::edit::parse_edit_text(text, col, context) { - Ok(CellInput::Value(value)) => Ok(value), - // A draft holds NULL for a cell left to the server; the save - // leaves that column out of the INSERT. - Ok(CellInput::Default) => Ok(Value::Null), - Err(EditParseError::Required) => Err(crate::i18n::gettext("Field is required")), - Err(error) => Err(error.to_string()), - } -} - -/// Format a positive integer with thousands separators (1000 → 1,000). -/// Used for page-size dropdown labels so they read naturally instead -/// of the abbreviated "1 K" / "5 K" form. -fn format_thousands(n: u64) -> String { - let s = n.to_string(); - let bytes = s.as_bytes(); - let len = bytes.len(); - let mut out = String::with_capacity(len + len / 3); - for (i, b) in bytes.iter().enumerate() { - if i > 0 && (len - i).is_multiple_of(3) { - out.push(','); - } - out.push(*b as char); - } - out -} - -/// Bundle of widgets returned by `build_paginator` so the builder -/// signature stays narrow. -struct Paginator { - bar: gtk::ActionBar, - insert_button: gtk::Button, - first_button: gtk::Button, - prev_button: gtk::Button, - next_button: gtk::Button, - last_button: gtk::Button, - filter_button: gtk::Button, - filter_badge: gtk::Label, - paginator_label: gtk::Label, - selection_label: gtk::Label, -} - -/// Bundle of widgets returned by `build_pending_revealer`. -struct PendingRevealer { - widget: gtk::Revealer, - save_button: gtk::Button, - discard_button: gtk::Button, - pending_label: gtk::Label, -} - -/// The result-column view of a catalogue column list, for a result the -/// grid renders from the schema it already has. -fn result_columns(columns: &[tablepro_core::ColumnInfo]) -> std::sync::Arc<[tablepro_core::column::ResultColumn]> { - columns.iter().map(|column| column.result_column()).collect() -} - -#[cfg(test)] -mod tests { - use gtk4::prelude::*; - - use super::{apply_visual_change, build_persisted_row_key, format_thousands, parse_input_for_column}; - use crate::services::change_tracker::RowKey; - use crate::services::tracker_delta::VisualChange; - use crate::ui::row_object::RowObject; - use tablepro_core::edit::EditContext; - use tablepro_core::{ColumnInfo, Value}; - - /// A column carrying the server's spelling and the kind it means. - #[expect( - clippy::disallowed_methods, - reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" - )] - fn col(data_type: &str, nullable: bool) -> ColumnInfo { - let kind = tablepro_core::column::classify_type_name(data_type); - ColumnInfo { - name: "x".into(), - column_type: tablepro_core::column::ColumnType { - name: data_type.to_owned(), - dynamic_storage: tablepro_core::column::has_dynamic_storage(&kind), - kind, - catalog: Some(tablepro_core::column::CatalogType { - declaration: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), - cast_target: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), - }), - read_form: tablepro_core::column::ReadForm::Native, - }, - nullable, - primary_key: false, - is_auto_increment: false, - is_generated: false, - default: tablepro_core::column::ColumnDefault::None, - comment: None, - } - } - - #[expect( - clippy::disallowed_methods, - reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" - )] - fn col_with_default(data_type: &str, default: &str) -> ColumnInfo { - let mut c = col(data_type, false); - c.default = tablepro_core::column::ColumnDefault::Expression( - tablepro_core::column::SqlExpression::from_catalog_text(default), - ); - c - } - - #[test] - fn format_thousands_handles_common_page_sizes() { - assert_eq!(format_thousands(100), "100"); - assert_eq!(format_thousands(500), "500"); - assert_eq!(format_thousands(1_000), "1,000"); - assert_eq!(format_thousands(5_000), "5,000"); - assert_eq!(format_thousands(10_000), "10,000"); - assert_eq!(format_thousands(1_000_000), "1,000,000"); - } - - #[test] - fn format_thousands_handles_edges() { - assert_eq!(format_thousands(0), "0"); - assert_eq!(format_thousands(1), "1"); - assert_eq!(format_thousands(999), "999"); - } - - #[test] - fn clearing_a_text_cell_stores_the_empty_string() { - // A text column has an empty value, so clearing the cell is - // that value rather than NULL. There was no way to type it - // before. - let cleared = - parse_input_for_column("", Some(&col("text", true)), EditContext::UpdateRow).expect("an empty string"); - - assert_eq!(cleared, Value::Text(String::new())); - } - - #[test] - fn clearing_a_column_with_no_empty_value_yields_null() { - let cleared = - parse_input_for_column("", Some(&col("timestamp", true)), EditContext::UpdateRow).expect("a null"); - - assert!(matches!(cleared, Value::Null)); - } - - #[test] - fn empty_on_not_null_with_default_is_left_to_the_server_only_in_a_draft() { - let column = col_with_default("timestamp", "now()"); - - let draft = parse_input_for_column("", Some(&column), EditContext::InsertDraft).unwrap(); - assert!(matches!(draft, Value::Null), "a draft leaves it to the default"); - - let existing = parse_input_for_column("", Some(&column), EditContext::UpdateRow); - assert!(existing.is_err(), "an UPDATE cannot ask for the default: {existing:?}"); - } - - #[test] - fn empty_on_a_required_column_with_no_empty_value_is_rejected() { - let r = parse_input_for_column("", Some(&col("integer", false)), EditContext::UpdateRow); - - assert!(r.is_err()); - assert!(r.as_ref().is_err_and(|message| message.contains("required")), "{r:?}"); - } - - #[test] - fn parses_int_decimal_float_bool() { - assert!(matches!( - parse_input_for_column("42", Some(&col("integer", false)), EditContext::UpdateRow).unwrap(), - Value::Int(42) - )); - assert!(matches!( - parse_input_for_column("3.14", Some(&col("real", false)), EditContext::UpdateRow).unwrap(), - Value::Float32(_) - )); - assert!(matches!( - parse_input_for_column("99.99", Some(&col("decimal(10,2)", false)), EditContext::UpdateRow).unwrap(), - Value::Decimal(_) - )); - assert!(matches!( - parse_input_for_column("yes", Some(&col("boolean", false)), EditContext::UpdateRow).unwrap(), - Value::Bool(true) - )); - // MySQL spells boolean as tinyint(1), so it parses as the - // integer it is and the grid renders 0 and 1. - assert!(matches!( - parse_input_for_column("0", Some(&col("tinyint(1)", false)), EditContext::UpdateRow).unwrap(), - Value::Int(0) - )); - } - - #[test] - fn parses_uuid_json_date_time_datetime_timestamptz() { - let uuid = parse_input_for_column( - "550e8400-e29b-41d4-a716-446655440000", - Some(&col("uuid", false)), - EditContext::UpdateRow, - ) - .unwrap(); - assert!(matches!(uuid, Value::Uuid(_))); - - let json = parse_input_for_column(r#"{"a":1}"#, Some(&col("jsonb", false)), EditContext::UpdateRow).unwrap(); - assert!(matches!(json, Value::Json(_))); - - let date = parse_input_for_column("2024-01-15", Some(&col("date", false)), EditContext::UpdateRow).unwrap(); - assert!(matches!(date, Value::Date(_))); - - let time = parse_input_for_column("14:30:00", Some(&col("time", false)), EditContext::UpdateRow).unwrap(); - assert!(matches!(time, Value::Time(_))); - let time_short = parse_input_for_column("14:30", Some(&col("time", false)), EditContext::UpdateRow).unwrap(); - assert!(matches!(time_short, Value::Time(_))); - - let datetime = parse_input_for_column( - "2024-01-15 14:30:00", - Some(&col("timestamp", false)), - EditContext::UpdateRow, - ) - .unwrap(); - assert!(matches!(datetime, Value::Timestamp(_))); - let datetime_t = parse_input_for_column( - "2024-01-15T14:30:00", - Some(&col("datetime", false)), - EditContext::UpdateRow, - ) - .unwrap(); - assert!(matches!(datetime_t, Value::Timestamp(_))); - } - - #[test] - fn rejects_invalid_type_specific_input() { - assert!(parse_input_for_column("not-a-number", Some(&col("integer", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("not-a-uuid", Some(&col("uuid", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("{not json", Some(&col("jsonb", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("2024/01/15", Some(&col("date", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("13:00:99", Some(&col("time", false)), EditContext::UpdateRow).is_err()); - assert!( - parse_input_for_column("128", Some(&col("tinyint", false)), EditContext::UpdateRow).is_err(), - "a number past the column's range was accepted" - ); - assert!(parse_input_for_column("not-a-date", Some(&col("timestamp", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("maybe", Some(&col("boolean", false)), EditContext::UpdateRow).is_err()); - } - - #[test] - fn unknown_type_falls_through_to_text() { - let r = parse_input_for_column( - "anything goes here", - Some(&col("varchar(255)", false)), - EditContext::UpdateRow, - ) - .unwrap(); - assert!(matches!(r, Value::Text(_))); - } - - #[test] - fn null_sentinel_typed_literally_is_text() { - // Column is text + nullable; user types "" literally. We - // do NOT special-case the sentinel string — only an actually- - // empty input becomes Null. The visual sentinel is cleared by - // the grid before edit (grid.rs install_double_click_to_edit), - // so this path is only reached if the user typed `` on - // purpose. - let r = parse_input_for_column("", Some(&col("text", true)), EditContext::UpdateRow).unwrap(); - match r { - Value::Text(s) => assert_eq!(s, ""), - other => panic!("expected Text(\"\") got {other:?}"), - } - } - - // build_persisted_row_key — pure helper used by row_key_at and - // the bulk-delete snapshot loop. Locks down the contract that - // backed the position-space bug fix: cells come straight from - // the row, pk_indices select which positions form the PK. - - #[test] - fn build_pk_single_column() { - let cells = vec![Value::Int(42), Value::Text("alice".into())]; - let (key, returned) = build_persisted_row_key(cells.clone(), &[0]).expect("valid PK"); - assert!(matches!(key, RowKey::Persisted(_))); - assert_eq!(returned, cells); - } - - #[test] - fn build_pk_multi_column_preserves_index_order() { - // Composite PK formed from columns 2 and 0, in that order. - // The helper must preserve `pk_indices` ordering so two - // tables with the same columns in different orders never - // produce key-collisions on rows that aren't actually equal. - let cells = vec![ - Value::Text("eu-west".into()), - Value::Text("ignored".into()), - Value::Int(7), - ]; - let (key, _) = build_persisted_row_key(cells, &[2, 0]).expect("composite PK"); - let RowKey::Persisted(kv) = key else { - panic!("expected Persisted") - }; - // First component is column-2 (Int 7), second is column-0 - // (Text "eu-west"). Reversed order would be the bug. - assert_eq!(kv.len(), 2); - } - - #[test] - fn build_pk_empty_indices_returns_none() { - // Tables with no PK can't have stable row identity. The - // caller is expected to short-circuit before reaching the - // helper, but defending here means the helper is safe to - // call from any context. - let cells = vec![Value::Int(1)]; - assert!(build_persisted_row_key(cells, &[]).is_none()); - } - - #[test] - fn build_pk_index_out_of_range_returns_none() { - // PK index 5 against a 2-cell row was the actual bug class - // we just fixed — the previous code would index past the - // end of `result.rows[pos]` when drafts shifted positions. - // The helper now returns None instead of panicking. - let cells = vec![Value::Int(1), Value::Text("x".into())]; - assert!(build_persisted_row_key(cells, &[5]).is_none()); - } - - #[test] - fn build_pk_partial_out_of_range_returns_none() { - // Composite PK where one index is valid and one isn't — - // any out-of-range component invalidates the whole key. - let cells = vec![Value::Int(1)]; - assert!(build_persisted_row_key(cells, &[0, 1]).is_none()); - } - - #[test] - fn build_pk_with_null_components_is_allowed() { - // SQL allows NULL in primary keys for some drivers (rare - // but legal in SQLite, MySQL with NULL columns, etc.). - // The tracker's KeyValue::Null mirror handles equality, so - // the helper must not reject Null PK components — only - // out-of-range or empty pk_indices fail. - let cells = vec![Value::Null, Value::Text("x".into())]; - let (key, _) = build_persisted_row_key(cells, &[0]).expect("Null PK is valid"); - assert!(matches!(key, RowKey::Persisted(_))); - } - - fn row_at(store: >k4::gio::ListStore, position: u32) -> RowObject { - store.item(position).and_downcast::().expect("a row") - } - - #[test] - fn visual_changes_add_update_and_remove_draft_rows() { - let store = gtk4::gio::ListStore::new::(); - store.append(&RowObject::new(vec![Value::Int(1)])); - - apply_visual_change( - &store, - &VisualChange::InsertDraft { - draft_id: 4, - values: vec![Value::Null], - }, - ); - apply_visual_change( - &store, - &VisualChange::SetDraftCell { - draft_id: 4, - col: 0, - value: Value::Int(9), - }, - ); - assert_eq!(row_at(&store, 0).draft_id(), Some(4)); - assert_eq!(row_at(&store, 0).cell_value(0), Value::Int(9)); - - apply_visual_change(&store, &VisualChange::RemoveDraft { draft_id: 4 }); - assert_eq!(store.n_items(), 1); - assert_eq!(row_at(&store, 0).draft_id(), None); - } - - #[gtk4::test] - fn browse_tab_and_filter_strip_freed_after_drop() { - use relm4::{Component, ComponentController}; - - libadwaita::init().expect("libadwaita initialises under the test backend"); - let runtime = crate::test_support::test_runtime(); - let root = tempfile::tempdir().expect("tempdir"); - let controller = super::BrowseTab::builder() - .launch(super::BrowseTabInit { - tracker: crate::services::tab_tracker_handle::TabTrackerHandle::default(), - schema: None, - table: "t".to_owned(), - connection_id: Some(uuid::Uuid::new_v4()), - read_only: false, - page_size: 100, - initial_offset: 0, - initial_sort: None, - column_widths: crate::persistence::ColumnWidthStore::load( - root.path().join("column-widths.json"), - &runtime.tasks(), - ), - filter_settings: crate::persistence::FilterSettingsStore::load( - root.path().join("filters.json"), - &runtime.tasks(), - ), - }) - .detach(); - crate::test_support::drain_main_context(); - let strip = controller - .model() - .filter_strip - .as_ref() - .map(|strip| strip.widget.downgrade()) - .expect("the filter strip"); - let tab = controller.widget().downgrade(); - - drop(controller); - crate::test_support::drain_main_context(); - - assert!(strip.upgrade().is_none(), "the filter strip outlived its tab"); - assert!(tab.upgrade().is_none(), "the browse tab outlived its controller"); - } -} diff --git a/linux/crates/app/src/ui/browse_tab/browse_command.rs b/linux/crates/app/src/ui/browse_tab/browse_command.rs new file mode 100644 index 0000000000..9a96baa97a --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/browse_command.rs @@ -0,0 +1,25 @@ +use std::sync::Arc; + +use tablepro_core::ColumnInfo; +use tablepro_core::meta::RowIdentity; +use tablepro_session::OperationError; + +use super::page_reply::PageReply; +use crate::runtime::Stamped; + +/// The table's columns from the session's catalog, and how its rows are +/// told apart, read together because a page needs both. +#[derive(Debug, Clone)] +pub(crate) struct ColumnsReply { + pub columns: Arc<[ColumnInfo]>, + pub identity: RowIdentity, +} + +/// A reply to one of the tab's reads, stamped with the request it +/// answers so a reply to a replaced request is dropped. +#[derive(Debug)] +pub(crate) enum BrowseCommand { + Columns(Stamped>>), + Page(Stamped>), + Count(Stamped>), +} diff --git a/linux/crates/app/src/ui/browse_tab/browse_request_kind.rs b/linux/crates/app/src/ui/browse_tab/browse_request_kind.rs new file mode 100644 index 0000000000..07c221d019 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/browse_request_kind.rs @@ -0,0 +1,10 @@ +/// The reads a browse tab makes. Each kind has at most one request +/// running; a new one replaces the last. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum BrowseRequestKind { + /// The table's columns and how its rows are identified. A page and + /// a count are built from them, so both wait for this one. + Columns, + Page, + Count, +} diff --git a/linux/crates/app/src/ui/browse_tab/fetch.rs b/linux/crates/app/src/ui/browse_tab/fetch.rs new file mode 100644 index 0000000000..b6b9df5bc9 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/fetch.rs @@ -0,0 +1,314 @@ +use std::future::Future; +use std::sync::Arc; + +use tablepro_core::browse::{PageRequest, SortKey, build_count, build_page, split_page}; +use tablepro_core::meta::{PagePosition, RowIdentity, TableRef}; +use tablepro_core::statement::StatementBuilder; +use tablepro_core::{ColumnInfo, FilterSet, QueryResult, RowLimit}; +use tablepro_session::{LinkGeneration, OperationError, OperationKind}; + +use super::{ + BrowseCommand, BrowseModel, BrowseRequestKind, BrowseTabInput, BrowseTabOutput, ColumnsReply, Effects, PageReply, +}; +use crate::runtime::{RequestTicket, Stamped}; + +/// What a page or a count reads, copied off the tab so the work owns +/// it. The SQL is built inside the operation from the lease it runs on, +/// so it is spelled for that server even when a reconnect lands first. +#[derive(Debug, Clone)] +struct ReadPlan { + table: TableRef, + columns: Vec, + identity: RowIdentity, + filter: FilterSet, + sort: Vec, + offset: u64, + limit: u64, +} + +impl BrowseModel { + /// Ask for one kind of read. A page or a count waits for the + /// columns it is built from. + pub(super) fn fetch(&mut self, kind: BrowseRequestKind, fx: &mut Effects) { + match kind { + BrowseRequestKind::Columns => self.fetch_columns(false), + BrowseRequestKind::Page | BrowseRequestKind::Count if self.identity.is_none() => { + self.after_columns.insert(kind); + if !self.sequencer.is_pending(BrowseRequestKind::Columns) { + self.fetch_columns(false); + } + } + BrowseRequestKind::Page => self.fetch_page(fx), + BrowseRequestKind::Count => self.fetch_count(), + } + } + + /// Read the columns, then the page and the count built from them. + /// `refresh` asks the server even when the catalog has the columns. + pub(super) fn reload_from_columns(&mut self, refresh: bool) { + self.after_columns.insert(BrowseRequestKind::Page); + self.after_columns.insert(BrowseRequestKind::Count); + self.fetch_columns(refresh); + } + + fn issue(&mut self, kind: BrowseRequestKind) -> RequestTicket { + self.issued_on.insert(kind, self.session.link_generation()); + self.sequencer.issue(kind) + } + + fn fetch_columns(&mut self, refresh: bool) { + let ticket = self.issue(BrowseRequestKind::Columns); + let session = self.session.clone(); + let table = self.table_ref(); + let cancel = ticket.cancel.clone(); + let work = async move { + // The catalog's read is shared with other views, so a + // replaced request stops waiting on it without stopping it. + let columns = tokio::select! { + columns = async { + match refresh { + true => session.refresh_columns(table.clone()).await, + false => session.columns(table.clone()).await, + } + } => columns?, + () = cancel.cancelled() => return Err(Arc::new(OperationError::Cancelled)), + }; + let identity = session + .run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| { + let table = table.clone(); + async move { Ok(lease.row_identity(&table, options).await?) } + }) + .await + .map_err(Arc::new)?; + Ok(ColumnsReply { columns, identity }) + }; + self.deliver(ticket, work, BrowseCommand::Columns, |failure| { + Arc::new(OperationError::from(failure)) + }); + } + + fn fetch_page(&mut self, fx: &mut Effects) { + let Some(plan) = self.read_plan() else { + return; + }; + // A filter the dialect cannot write is the user's to fix, so it + // is a toast now rather than a failed page later. + if let Some(message) = self.filter_problem() { + fx.output(BrowseTabOutput::ShowToast(message)); + return; + } + let ticket = self.issue(BrowseRequestKind::Page); + let session = self.session.clone(); + let cancel = ticket.cancel.clone(); + let position = match plan.offset { + 0 => PagePosition::First, + offset => PagePosition::Offset(offset), + }; + let work = async move { + let reply_position = position.clone(); + session + .run_cancellable(OperationKind::Page, &cancel, move |lease, options| { + let plan = plan.clone(); + let position = position.clone(); + async move { + let spec = build_page( + &*lease.dialect(), + &PageRequest { + table: &plan.table, + columns: &plan.columns, + filter: &plan.filter, + sort: &plan.sort, + identity: &plan.identity, + position: &position, + limit: plan.limit, + }, + )?; + let limit = RowLimit::new(plan.limit).unwrap_or(RowLimit::EDITOR_DEFAULT); + let result = lease.query_bound(&spec.statement, limit, options).await?; + let page = split_page(result, &spec)?; + Ok(QueryResult::new(page.columns, page.rows).truncated(page.truncated)) + } + }) + .await + .map(|result| PageReply { + position: reply_position, + result, + }) + }; + self.deliver(ticket, work, BrowseCommand::Page, OperationError::from); + } + + /// The "of N" total, under the same filter as the page so the two + /// agree. The page toasts a filter that cannot be written, so the + /// count stays quiet about it. + fn fetch_count(&mut self) { + let Some(plan) = self.read_plan() else { + return; + }; + if self.filter_problem().is_some() { + return; + } + let ticket = self.issue(BrowseRequestKind::Count); + let session = self.session.clone(); + let cancel = ticket.cancel.clone(); + let work = async move { + session + .run_cancellable(OperationKind::Count, &cancel, move |lease, options| { + let plan = plan.clone(); + async move { + let statement = build_count(&*lease.dialect(), &plan.table, &plan.columns, &plan.filter)?; + Ok(lease.count_rows(&statement, options).await?) + } + }) + .await + }; + self.deliver(ticket, work, BrowseCommand::Count, OperationError::from); + } + + /// Run `work` on the app runtime and hand its stamped reply back to + /// the tab on the GTK thread. The delivery holds the tab weakly, so a + /// tab closed meanwhile is not kept alive by its own reads. + fn deliver( + &self, + ticket: RequestTicket, + work: W, + command: impl FnOnce(Stamped>) -> BrowseCommand + 'static, + failed: impl FnOnce(tablepro_session::runtime::TaskFailure) -> E + 'static, + ) where + T: Send + 'static, + E: Send + 'static, + W: Future> + Send + 'static, + { + let tab = self.this.clone(); + let stamp = ticket.stamp; + let _delivery = crate::runtime::spawn_and_deliver(&self.tasks, work, move |joined| { + let value = joined.unwrap_or_else(|failure| Err(failed(failure))); + if let Some(tab) = tab.upgrade() { + tab.send(BrowseTabInput::Command(command(Stamped { stamp, value }))); + } + }); + } + + fn read_plan(&self) -> Option { + Some(ReadPlan { + table: self.table_ref(), + columns: self.current_columns.clone(), + identity: self.identity.clone()?, + filter: self.current_filter.clone(), + sort: self + .current_sort + .map(|(column, ascending)| SortKey { column, ascending }) + .into_iter() + .collect(), + offset: self.current_offset, + limit: self.page_size, + }) + } + + /// Why the tab's filter cannot be written for the current server, + /// if it cannot. + fn filter_problem(&self) -> Option { + let dialect = self.dialect.as_ref()?; + let mut builder = StatementBuilder::new(); + tablepro_core::build_filter(&mut builder, &**dialect, &self.current_columns, &self.current_filter) + .err() + .map(|error| error.to_string()) + } + + pub(super) fn on_command(&mut self, command: BrowseCommand, fx: &mut Effects) { + match command { + BrowseCommand::Columns(Stamped { stamp, value }) => { + if !self.sequencer.accept(BrowseRequestKind::Columns, stamp) { + return; + } + self.failed.record(BrowseRequestKind::Columns, &value); + match value { + Ok(reply) => { + self.identity = Some(reply.identity); + self.on_columns_loaded(reply.columns.to_vec(), fx); + for kind in std::mem::take(&mut self.after_columns) { + self.fetch(kind, fx); + } + } + Err(error) => { + tracing::warn!(%error, "could not read the table's columns"); + self.show_error(&crate::ui::error_text::operation_error_message(&error)); + self.retry_if_link_moved(BrowseRequestKind::Columns, fx); + } + } + } + BrowseCommand::Page(Stamped { stamp, value }) => { + if !self.sequencer.accept(BrowseRequestKind::Page, stamp) { + return; + } + self.failed.record(BrowseRequestKind::Page, &value); + match value { + Ok(reply) => { + let offset = reply.offset().unwrap_or(self.current_offset); + self.on_rows_loaded(offset, reply.result, fx); + } + Err(error) => { + self.show_error(&crate::ui::error_text::operation_error_message(&error)); + self.retry_if_link_moved(BrowseRequestKind::Page, fx); + } + } + } + BrowseCommand::Count(Stamped { stamp, value }) => { + if !self.sequencer.accept(BrowseRequestKind::Count, stamp) { + return; + } + self.failed.record(BrowseRequestKind::Count, &value); + match value { + Ok(count) => self.on_row_count_loaded(count, fx), + Err(error) => { + tracing::debug!(%error, "the row count could not be read"); + self.retry_if_link_moved(BrowseRequestKind::Count, fx); + } + } + } + } + } + + /// The session reconnected. Reads that failed are asked again on the + /// new connection; a tab whose reads all landed keeps its rows. + pub(super) fn on_link_changed(&mut self, generation: LinkGeneration, fx: &mut Effects) { + if generation <= self.link_generation { + return; + } + self.link_generation = generation; + self.dialect = self.session.current_dialect(); + self.retry_failed(fx); + } + + /// A read that failed after its connection was already replaced + /// would wait for a reconnect that has come and gone, so it is + /// asked again now. + fn retry_if_link_moved(&mut self, kind: BrowseRequestKind, fx: &mut Effects) { + let issued = self.issued_on.get(&kind).copied(); + if issued.is_some_and(|issued| self.session.link_generation() > issued) { + self.retry_failed(fx); + } + } + + fn retry_failed(&mut self, fx: &mut Effects) { + if self.failed.is_empty() { + return; + } + let kinds = self.failed.take(); + if kinds.contains(&BrowseRequestKind::Page) || kinds.contains(&BrowseRequestKind::Columns) { + self.show_loading(); + } + match kinds.contains(&BrowseRequestKind::Columns) { + true => { + self.after_columns + .extend(kinds.into_iter().filter(|kind| *kind != BrowseRequestKind::Columns)); + self.fetch_columns(false); + } + false => { + for kind in kinds { + self.fetch(kind, fx); + } + } + } + } +} diff --git a/linux/crates/app/src/ui/browse_tab/imp.rs b/linux/crates/app/src/ui/browse_tab/imp.rs new file mode 100644 index 0000000000..6681de7ba6 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/imp.rs @@ -0,0 +1,209 @@ +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::gdk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use tablepro_session::runtime::WatchForwarder; + +use super::{BrowseModel, BrowseTabInput}; + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/browse-tab.ui")] +pub struct BrowseTab { + #[template_child] + pub toolbar: TemplateChild, + #[template_child] + pub read_only_banner: TemplateChild, + #[template_child] + pub no_pk_banner: TemplateChild, + #[template_child] + pub inner_stack: TemplateChild, + #[template_child] + pub loading_page: TemplateChild, + #[template_child] + pub grid_holder: TemplateChild, + #[template_child] + pub error_page: TemplateChild, + #[template_child] + pub insert_button: TemplateChild, + #[template_child] + pub first_button: TemplateChild, + #[template_child] + pub prev_button: TemplateChild, + #[template_child] + pub next_button: TemplateChild, + #[template_child] + pub last_button: TemplateChild, + #[template_child] + pub paginator_label: TemplateChild, + #[template_child] + pub selection_label: TemplateChild, + #[template_child] + pub export_button: TemplateChild, + #[template_child] + pub filter_button: TemplateChild, + #[template_child] + pub filter_badge: TemplateChild, + #[template_child] + pub page_size_dropdown: TemplateChild, + #[template_child] + pub pending_revealer: TemplateChild, + #[template_child] + pub pending_label: TemplateChild, + #[template_child] + pub discard_button: TemplateChild, + #[template_child] + pub save_button: TemplateChild, + + pub(super) model: RefCell>, + pub(super) queue: RefCell>, + pub(super) dispatching: Cell, + /// Delivers each reconnect, for as long as the tab is open. + pub(super) link_watch: RefCell>, + pub(super) grid_forward: RefCell>>, +} + +#[glib::object_subclass] +impl ObjectSubclass for BrowseTab { + const NAME: &'static str = "TableProBrowseTab"; + type Type = super::BrowseTab; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + install_bindings(klass); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +/// The grid's keyboard model. The bindings act only while the focus is +/// inside this tab, so the editor and the other tabs keep their own. +fn install_bindings(klass: &mut ::Class) { + let none = gdk::ModifierType::empty(); + let ctrl = gdk::ModifierType::CONTROL_MASK; + let send = |input: fn() -> BrowseTabInput, propagation: glib::Propagation| { + move |tab: &super::BrowseTab| { + tab.send(input()); + propagation + } + }; + // Esc clears a multi-row selection and lets the key go on, so a + // search bar or dialog above still closes on it. + klass.add_binding( + gdk::Key::Escape, + none, + send(|| BrowseTabInput::ClearSelection, glib::Propagation::Proceed), + ); + klass.add_binding( + gdk::Key::Delete, + none, + send(|| BrowseTabInput::DeleteSelectedRow, glib::Propagation::Stop), + ); + klass.add_binding( + gdk::Key::n, + ctrl, + send(|| BrowseTabInput::InsertRow, glib::Propagation::Stop), + ); + // No GNOME precedent for "set NULL"; Ctrl+Backspace is delete-word + // in every text field, so the null shortcut is Ctrl+Shift+N. + klass.add_binding(gdk::Key::n, ctrl | gdk::ModifierType::SHIFT_MASK, |tab| { + tab.set_focused_cell_null() + }); + // A cell editor's text field consumes Ctrl+C and Ctrl+V first, so + // these only see the grid's own copy and paste. + klass.add_binding( + gdk::Key::c, + ctrl, + send(|| BrowseTabInput::CopySelectedRowsAsTsv, glib::Propagation::Stop), + ); + klass.add_binding( + gdk::Key::v, + ctrl, + send(|| BrowseTabInput::PasteNotSupported, glib::Propagation::Stop), + ); + klass.add_binding( + gdk::Key::a, + ctrl, + send(|| BrowseTabInput::SelectAllRows, glib::Propagation::Stop), + ); + // Paging is by offset, so scrolling stops at the page edge; Page Up + // and Page Down move between pages instead. + klass.add_binding( + gdk::Key::Page_Up, + none, + send(|| BrowseTabInput::PrevPage, glib::Propagation::Stop), + ); + klass.add_binding( + gdk::Key::Page_Down, + none, + send(|| BrowseTabInput::NextPage, glib::Propagation::Stop), + ); + klass.add_binding( + gdk::Key::Home, + ctrl, + send(|| BrowseTabInput::GoToFirstRow, glib::Propagation::Stop), + ); + klass.add_binding( + gdk::Key::End, + ctrl, + send(|| BrowseTabInput::GoToLastRow, glib::Propagation::Stop), + ); +} + +impl ObjectImpl for BrowseTab { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("state-changed").build(), + Signal::builder("columns-changed").build(), + Signal::builder("copy-row-as-insert") + .param_types([u32::static_type()]) + .build(), + Signal::builder("copy-to-clipboard") + .param_types([String::static_type()]) + .build(), + Signal::builder("export-results") + .param_types([glib::BoxedAnyObject::static_type()]) + .build(), + Signal::builder("alert") + .param_types([String::static_type(), String::static_type()]) + .build(), + Signal::builder("toast").param_types([String::static_type()]).build(), + Signal::builder("dirty-changed") + .param_types([bool::static_type()]) + .build(), + Signal::builder("save-requested") + .param_types([glib::BoxedAnyObject::static_type()]) + .build(), + ] + }) + } + + /// Closing the tab stops every read it has running, so the server + /// drops the statement rather than finishing it for nobody. + fn dispose(&self) { + self.link_watch.take(); + if let Some(forwarding) = self.grid_forward.take() { + forwarding.abort(); + } + if let Ok(mut model) = self.model.try_borrow_mut() + && let Some(mut model) = model.take() + { + model.sequencer.cancel_all(); + } + } +} + +impl WidgetImpl for BrowseTab {} +impl BinImpl for BrowseTab {} diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs new file mode 100644 index 0000000000..3f8cdd0626 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -0,0 +1,1973 @@ +//! A table's rows in a workspace tab. +//! +//! The tab reads its own columns, pages and row count through the +//! session, one request of each kind at a time: a newer request stops +//! the one it replaces, and a reply to a replaced request is dropped. +//! A read that failed is asked again once the session reconnects. + +mod browse_command; +mod browse_request_kind; +mod fetch; +mod imp; +mod page_reply; +#[cfg(test)] +mod tests; + +use std::cell::{Cell, RefCell}; +use std::collections::{BTreeSet, VecDeque}; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::edit::{CellInput, EditContext, EditParseError}; +use tablepro_core::meta::{RowIdentity, TableRef}; +use tablepro_core::{ColumnInfo, QueryResult, Value}; +use tablepro_session::runtime::{Tasks, WatchForwarder}; +use tablepro_session::{LinkGeneration, Session}; + +pub(crate) use self::browse_command::{BrowseCommand, ColumnsReply}; +pub(crate) use self::browse_request_kind::BrowseRequestKind; +pub(crate) use self::page_reply::PageReply; +use super::grid::{CellPreset, GridMsg, TabGridContext, build_column_view}; +use crate::runtime::{FailedRequests, RequestSequencer}; +use crate::services::change_tracker::{RowKey, StatementSource, TrackedChanges}; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::services::tracker_delta::{TrackerDelta, VisualChange}; +use crate::services::tracker_edit::TrackerEdit; + +const PAGE_SIZE_OPTIONS: &[u64] = &[100, 500, 1_000, 5_000, 10_000]; +const DEFAULT_PAGE_SIZE: u64 = 1_000; +/// Bulk-delete safety net: when the user marks at least this many +/// rows pending-delete in one shot, surface a confirmation dialog +/// before tracking. The marker is reversible via Discard / Ctrl+Z, +/// but a 200-row Ctrl+A → Delete sequence is destructive enough at +/// a glance that an explicit confirmation matches GNOME Files' +/// "Delete N items?" pattern. +const BULK_DELETE_CONFIRM_THRESHOLD: usize = 10; + +glib::wrapper! { + pub struct BrowseTab(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +pub struct BrowseTabInit { + pub tracker: TabTrackerHandle, + pub schema: Option, + pub table: String, + pub connection_id: Option, + pub read_only: bool, + pub page_size: u64, + pub initial_offset: u64, + pub initial_sort: Option<(usize, bool)>, + pub column_widths: crate::persistence::ColumnWidthStore, + pub filter_settings: crate::persistence::FilterSettingsStore, + /// The session the tab reads through. The tab belongs to it and is + /// closed before it shuts down. + pub session: Arc, + pub tasks: Tasks, +} + +/// Rows the user asked to write to a file, and what to call it. +#[derive(Debug, Clone)] +pub struct ExportRequest { + pub result: QueryResult, + pub name: String, + pub target: Option, +} + +/// The tracker's changes to save as one batch. The columns are the ones +/// the change set was built against, so the plan binds the same shape; +/// `changes` maps a failing step back to its grid row. +#[derive(Debug, Clone)] +pub struct SaveRequest { + pub table: TableRef, + pub columns: Vec, + pub changes: TrackedChanges, +} + +impl BrowseTab { + pub fn new(init: BrowseTabInit) -> Self { + let tab: Self = glib::Object::new(); + let imp = tab.imp(); + + // Restore the saved filter for this (connection, schema, table) + // up front so both the model and the inline strip start with the + // same FilterSet. + let initial_filter = init + .connection_id + .map(|id| init.filter_settings.filters(id, init.schema.as_deref(), &init.table)) + .unwrap_or_default(); + let table_label = qualified(init.schema.as_deref(), &init.table); + imp.loading_page.set_description(Some(&crate::i18n::gettext_f( + "Fetching rows from {table}", + &[("table", &table_label)], + ))); + imp.inner_stack.set_visible_child_name("loading"); + imp.read_only_banner.set_revealed(init.read_only); + + let page_size_labels: Vec = PAGE_SIZE_OPTIONS.iter().map(|n| format_thousands(*n)).collect(); + let page_size_strs: Vec<&str> = page_size_labels.iter().map(String::as_str).collect(); + imp.page_size_dropdown + .set_model(Some(>k::StringList::new(&page_size_strs))); + let initial_idx = PAGE_SIZE_OPTIONS + .iter() + .position(|n| *n == init.page_size) + .unwrap_or_else(|| { + PAGE_SIZE_OPTIONS + .iter() + .position(|n| *n == DEFAULT_PAGE_SIZE) + .unwrap_or(2) + }); + imp.page_size_dropdown + .set_selected(u32::try_from(initial_idx).unwrap_or_default()); + + // Filter strip: an inline editor that slides down above the grid. + // It lives in this tab so the user's in-progress rules survive a + // click into a cell or the SQL editor. + let on_apply_filter: Rc = { + let tab = tab.downgrade(); + Rc::new(move |set| { + if let Some(tab) = tab.upgrade() { + tab.send(BrowseTabInput::FilterApplied(set)); + } + }) + }; + let filter_strip = crate::ui::filter_strip::build(Vec::new(), initial_filter.clone(), on_apply_filter); + imp.toolbar.add_top_bar(&filter_strip.widget); + tab.collapse_top_bars_with(&filter_strip.widget); + tab.connect_widgets(); + + let (grid_sender, grid_receiver) = relm4::channel::(); + let link_generation = init.session.link_generation(); + let dialect = init.session.current_dialect(); + let link_changes = init.session.link_changes(); + let model = BrowseModel { + this: tab.downgrade(), + session: init.session, + tasks: init.tasks, + sequencer: RequestSequencer::new(), + failed: FailedRequests::new(), + after_columns: BTreeSet::new(), + issued_on: std::collections::BTreeMap::new(), + link_generation, + identity: None, + dialect, + tracker: init.tracker, + schema: init.schema, + table: init.table, + connection_id: init.connection_id, + read_only: init.read_only, + column_widths: init.column_widths, + filter_settings: init.filter_settings, + current_offset: init.initial_offset, + page_size: init.page_size, + current_sort: init.initial_sort, + current_filter: initial_filter, + current_columns: Vec::new(), + current_result: None, + current_selection: None, + current_total_rows: None, + inner_stack: imp.inner_stack.get(), + grid_holder: imp.grid_holder.get(), + loading_page: imp.loading_page.get(), + error_page: imp.error_page.get(), + current_column_view: None, + rendered_column_count: Cell::new(0), + read_only_banner: imp.read_only_banner.get(), + no_pk_banner: imp.no_pk_banner.get(), + was_dirty: Cell::new(false), + pending_focus_restore: RefCell::new(None), + paginator_label: imp.paginator_label.get(), + selection_label: imp.selection_label.get(), + first_button: imp.first_button.get(), + prev_button: imp.prev_button.get(), + next_button: imp.next_button.get(), + last_button: imp.last_button.get(), + filter_button: imp.filter_button.get(), + filter_badge: imp.filter_badge.get(), + filter_strip: Some(filter_strip), + insert_button: imp.insert_button.get(), + pending_revealer: imp.pending_revealer.get(), + save_button: imp.save_button.get(), + discard_button: imp.discard_button.get(), + pending_label: imp.pending_label.get(), + grid_sender, + suppress_combo_emit: Cell::new(true), + }; + imp.model.replace(Some(model)); + tab.forward_grid(grid_receiver); + imp.link_watch.replace(Some(WatchForwarder::new( + link_changes, + { + let tab = tab.downgrade(); + move |generation: LinkGeneration| { + if let Some(tab) = tab.upgrade() { + tab.send(BrowseTabInput::LinkChanged(generation)); + } + } + }, + |future| { + glib::spawn_future_local(future); + }, + ))); + tab.send(BrowseTabInput::Start); + tab + } + + /// Hand the tab an input. One that arrives while another is being + /// handled, from a widget signal the handler set off, waits for it + /// to finish rather than running inside it. + pub(crate) fn send(&self, msg: BrowseTabInput) { + let imp = self.imp(); + imp.queue.borrow_mut().push_back(msg); + if imp.dispatching.replace(true) { + return; + } + loop { + let next = imp.queue.borrow_mut().pop_front(); + let Some(msg) = next else { + break; + }; + let mut effects = Effects::default(); + { + let mut model = imp.model.borrow_mut(); + let Some(model) = model.as_mut() else { + imp.queue.borrow_mut().clear(); + break; + }; + model.update(msg, &mut effects); + } + for output in effects.outputs { + self.emit_output(output); + } + imp.queue.borrow_mut().extend(effects.inputs); + } + imp.dispatching.set(false); + } + + /// Stop every read the tab has running and stop following + /// reconnects. Closing the tab calls this, so the server lets go of + /// the statement even while something still holds the widget. + pub fn shutdown(&self) { + let imp = self.imp(); + imp.link_watch.take(); + if let Ok(mut model) = imp.model.try_borrow_mut() + && let Some(model) = model.as_mut() + { + model.sequencer.cancel_all(); + } + } + + fn read(&self, read: impl FnOnce(&BrowseModel) -> T) -> Option { + self.imp().model.borrow().as_ref().map(read) + } + + pub fn snapshot(&self) -> Option { + self.read(|model| model.current_result.clone()).flatten() + } + + pub fn columns(&self) -> Vec { + self.read(|model| model.current_columns.clone()).unwrap_or_default() + } + + pub fn table_label(&self) -> String { + self.read(|model| qualified(model.schema.as_deref(), &model.table)) + .unwrap_or_default() + } + + pub fn table(&self) -> String { + self.read(|model| model.table.clone()).unwrap_or_default() + } + + pub fn current_offset(&self) -> u64 { + self.read(|model| model.current_offset).unwrap_or_default() + } + + pub fn page_size(&self) -> u64 { + self.read(|model| model.page_size).unwrap_or(DEFAULT_PAGE_SIZE) + } + + pub fn current_sort(&self) -> Option<(usize, bool)> { + self.read(|model| model.current_sort).flatten() + } + + pub fn connect_state_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure("state-changed", false, glib::closure_local!(move |tab: &Self| f(tab))) + } + + pub fn connect_columns_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure("columns-changed", false, glib::closure_local!(move |tab: &Self| f(tab))) + } + + pub fn connect_copy_row_as_insert(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "copy-row-as-insert", + false, + glib::closure_local!(move |tab: &Self, row: u32| f(tab, row)), + ) + } + + pub fn connect_copy_to_clipboard(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "copy-to-clipboard", + false, + glib::closure_local!(move |tab: &Self, text: String| f(tab, text)), + ) + } + + pub fn connect_export_results(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "export-results", + false, + glib::closure_local!(move |tab: &Self, payload: glib::BoxedAnyObject| { + f(tab, payload.borrow::().clone()); + }), + ) + } + + pub fn connect_alert(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "alert", + false, + glib::closure_local!(move |tab: &Self, title: String, body: String| f(tab, title, body)), + ) + } + + pub fn connect_toast(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "toast", + false, + glib::closure_local!(move |tab: &Self, text: String| f(tab, text)), + ) + } + + /// The pending changes crossed the empty / non-empty boundary. + pub fn connect_dirty_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "dirty-changed", + false, + glib::closure_local!(move |tab: &Self, dirty: bool| f(tab, dirty)), + ) + } + + pub fn connect_save_requested(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "save-requested", + false, + glib::closure_local!(move |tab: &Self, payload: glib::BoxedAnyObject| { + f(tab, payload.borrow::().clone()); + }), + ) + } + + fn emit_output(&self, output: BrowseTabOutput) { + match output { + BrowseTabOutput::StateChanged => self.emit_by_name::<()>("state-changed", &[]), + BrowseTabOutput::ColumnsChanged => self.emit_by_name::<()>("columns-changed", &[]), + BrowseTabOutput::CopyRowAsInsert { row_position } => { + self.emit_by_name::<()>("copy-row-as-insert", &[&row_position]); + } + BrowseTabOutput::CopyToClipboard(text) => self.emit_by_name::<()>("copy-to-clipboard", &[&text]), + BrowseTabOutput::ExportResults(request) => { + self.emit_by_name::<()>("export-results", &[&glib::BoxedAnyObject::new(request)]); + } + BrowseTabOutput::ShowSelectionAlert { title, body } => { + self.emit_by_name::<()>("alert", &[&title, &body]); + } + BrowseTabOutput::ShowToast(text) => self.emit_by_name::<()>("toast", &[&text]), + BrowseTabOutput::DirtyChanged(dirty) => self.emit_by_name::<()>("dirty-changed", &[&dirty]), + BrowseTabOutput::ExecuteTransaction(request) => { + self.emit_by_name::<()>("save-requested", &[&glib::BoxedAnyObject::new(request)]); + } + } + } + + /// The top-bar slot draws a thin strip even when every banner in it + /// is collapsed, so the slot itself is hidden unless one shows. + fn collapse_top_bars_with(&self, filter: >k::Revealer) { + let imp = self.imp(); + // The banners and the strip hold this through their notify + // handlers, and the tab holds them, so it holds all four weakly. + let sync: Rc = { + let toolbar = imp.toolbar.downgrade(); + let read_only = imp.read_only_banner.downgrade(); + let no_pk = imp.no_pk_banner.downgrade(); + let filter = filter.downgrade(); + Rc::new(move || { + let (Some(toolbar), Some(read_only), Some(no_pk), Some(filter)) = ( + toolbar.upgrade(), + read_only.upgrade(), + no_pk.upgrade(), + filter.upgrade(), + ) else { + return; + }; + toolbar.set_reveal_top_bars(read_only.is_revealed() || no_pk.is_revealed() || filter.reveals_child()); + }) + }; + sync(); + let read_only_sync = sync.clone(); + imp.read_only_banner.connect_revealed_notify(move |_| read_only_sync()); + let no_pk_sync = sync.clone(); + imp.no_pk_banner.connect_revealed_notify(move |_| no_pk_sync()); + filter.connect_reveal_child_notify(move |_| sync()); + } + + fn connect_widgets(&self) { + let imp = self.imp(); + let on_click = |button: >k::Button, msg: fn() -> BrowseTabInput| { + let tab = self.downgrade(); + button.connect_clicked(move |_| { + if let Some(tab) = tab.upgrade() { + tab.send(msg()); + } + }); + }; + on_click(&imp.insert_button, || BrowseTabInput::InsertRow); + on_click(&imp.first_button, || BrowseTabInput::FirstPage); + on_click(&imp.prev_button, || BrowseTabInput::PrevPage); + on_click(&imp.next_button, || BrowseTabInput::NextPage); + on_click(&imp.last_button, || BrowseTabInput::LastPage); + on_click(&imp.export_button, || BrowseTabInput::ExportCurrentPage); + on_click(&imp.discard_button, || BrowseTabInput::DiscardAll); + on_click(&imp.save_button, || BrowseTabInput::CommitSave); + let tab = self.downgrade(); + imp.page_size_dropdown.connect_selected_notify(move |dropdown| { + let index = usize::try_from(dropdown.selected()).unwrap_or(usize::MAX); + if let (Some(tab), Some(&size)) = (tab.upgrade(), PAGE_SIZE_OPTIONS.get(index)) { + tab.send(BrowseTabInput::PageSizeChanged(size)); + } + }); + } + + /// Each grid event becomes an input of this tab, until the tab goes. + fn forward_grid(&self, receiver: relm4::Receiver) { + let tab = self.downgrade(); + let forwarding = glib::spawn_future_local(async move { + while let Some(msg) = receiver.recv().await { + let Some(tab) = tab.upgrade() else { + break; + }; + tab.send(BrowseTabInput::from(msg)); + } + }); + self.imp().grid_forward.replace(Some(forwarding)); + } + + /// Set the focused cell to NULL, for Ctrl+Shift+N. + fn set_focused_cell_null(&self) -> glib::Propagation { + let Some((row_position, col_index)) = super::grid::focused_cell_coords(self) else { + return glib::Propagation::Proceed; + }; + if let Some(sender) = self.read(|model| model.grid_sender.clone()) { + sender + .send(GridMsg::SetCellValue { + row_position, + col_index, + preset: CellPreset::Null, + }) + .ok(); + } + glib::Propagation::Stop + } +} + +impl From for BrowseTabInput { + fn from(msg: GridMsg) -> Self { + match msg { + GridMsg::SortChanged(col_idx, ascending) => Self::SortChanged { col_idx, ascending }, + GridMsg::CellEdited { + row_position, + col_index, + new_value, + } => Self::GridCellEdited { + row_position, + col_index, + new_value, + }, + GridMsg::CopyToClipboard(text) => Self::GridCopyToClipboard(text), + GridMsg::ShowToast(text) => Self::GridShowToast(text), + GridMsg::CopyRowAsInsert { row_position } => Self::GridCopyRowAsInsert { row_position }, + GridMsg::SetCellValue { + row_position, + col_index, + preset, + } => Self::GridSetCellValue { + row_position, + col_index, + preset, + }, + GridMsg::ExportResults(result) => Self::GridExportResults(result), + GridMsg::DeleteRowAt { row_position } => Self::GridDeleteRowAt { row_position }, + GridMsg::InsertRow => Self::InsertRow, + GridMsg::DuplicateRow { row_position } => Self::DuplicateRow { row_position }, + } + } +} + +/// What one input left to do once it was handled: signals to emit and +/// inputs to handle next. +#[derive(Default)] +pub(crate) struct Effects { + outputs: Vec, + inputs: VecDeque, +} + +impl Effects { + fn output(&mut self, output: BrowseTabOutput) { + self.outputs.push(output); + } + + fn input(&mut self, input: BrowseTabInput) { + self.inputs.push_back(input); + } +} + +/// The tab's state and the widgets it updates. +pub(crate) struct BrowseModel { + this: glib::WeakRef, + session: Arc, + tasks: Tasks, + sequencer: RequestSequencer, + failed: FailedRequests, + /// Reads that wait for the columns, because a page and a count are + /// built from them. + after_columns: BTreeSet, + /// The connection each running request was issued on. A request + /// that fails after the connection was replaced is asked again at + /// once, since the reconnect it would wait for has already landed. + issued_on: std::collections::BTreeMap, + /// The newest connection the tab has heard of, so the watch's first + /// delivery, which is the one the tab opened on, changes nothing. + link_generation: LinkGeneration, + identity: Option, + /// The current connection's dialect, which says whether a filter can + /// be written for it. Read again after every reconnect. + dialect: Option>, + + tracker: TabTrackerHandle, + schema: Option, + table: String, + connection_id: Option, + read_only: bool, + column_widths: crate::persistence::ColumnWidthStore, + filter_settings: crate::persistence::FilterSettingsStore, + + current_offset: u64, + page_size: u64, + current_sort: Option<(usize, bool)>, + /// The WHERE every page and count reads under, kept per connection, + /// schema and table. + current_filter: tablepro_core::FilterSet, + current_columns: Vec, + current_result: Option, + current_selection: Option, + current_total_rows: Option, + + inner_stack: gtk::Stack, + grid_holder: gtk::Box, + loading_page: adw::StatusPage, + error_page: adw::StatusPage, + current_column_view: Option, + /// Column count the current view was built for; a page with the + /// same count reuses the view and only swaps its rows. + rendered_column_count: Cell, + /// Banners are for states the user cannot clear by saving; pending + /// edits show in the footer and the tab title instead. + read_only_banner: adw::Banner, + no_pk_banner: adw::Banner, + /// Last-emitted dirty state, so App hears only real transitions and + /// not every count change (2 → 3). + was_dirty: Cell, + /// The focused row before a sort, save or refresh, so the reloaded + /// page selects it again. + pending_focus_restore: RefCell>, + paginator_label: gtk::Label, + selection_label: gtk::Label, + first_button: gtk::Button, + prev_button: gtk::Button, + next_button: gtk::Button, + last_button: gtk::Button, + filter_button: gtk::Button, + filter_badge: gtk::Label, + filter_strip: Option, + insert_button: gtk::Button, + pending_revealer: gtk::Revealer, + save_button: gtk::Button, + discard_button: gtk::Button, + pending_label: gtk::Label, + grid_sender: relm4::Sender, + /// Set until the first page lands, so the page-size dropdown being + /// set from a restored state does not count as the user choosing. + suppress_combo_emit: Cell, +} + +#[derive(Debug)] +pub(crate) enum BrowseTabInput { + /// The tab is built: read the columns, then the page and the count. + Start, + /// A reply to one of the tab's reads. + Command(BrowseCommand), + /// A reconnect replaced the session's connection. + LinkChanged(LinkGeneration), + /// The table's definition changed. The catalog has already dropped + /// its columns, so they are read again before the page. + SchemaChanged, + /// F5. + Refresh, + /// Esc. A single selected row stays selected: clearing it would + /// strand the keyboard focus indicator. + ClearSelection, + ToggleFilterStrip, + /// The strip's rules, or an empty set for "Clear all". + FilterApplied(tablepro_core::FilterSet), + FirstPage, + PrevPage, + NextPage, + LastPage, + SortChanged { + col_idx: usize, + ascending: bool, + }, + PageSizeChanged(u64), + InsertRow, + /// Queued after an insert, so the focus lands on the column view + /// as it is once the insert is drawn. + FocusInsertedDraft, + DeleteSelectedRow, + GridCellEdited { + row_position: u32, + col_index: usize, + new_value: String, + }, + /// Cell context-menu "Set Value". The grid names the preset; the + /// tab resolves it against the column's declared type, because + /// only the tab holds the column metadata that says whether an + /// empty string is a value the column can hold. + GridSetCellValue { + row_position: u32, + col_index: usize, + preset: CellPreset, + }, + GridShowToast(String), + GridExportResults(QueryResult), + ExportCurrentPage, + GridDeleteRowAt { + row_position: u32, + }, + GridCopyRowAsInsert { + row_position: u32, + }, + DuplicateRow { + row_position: u32, + }, + GridCopyToClipboard(String), + Undo, + Redo, + CommitSave, + DiscardAll, + DiscardAndRefresh, + /// A delete the user confirmed, or one small enough to need no + /// confirmation. + MarkRowsDeleted { + rows: Vec<(RowKey, Vec)>, + drafts: Vec, + }, + SaveCompleted, + /// The pending changes stay, so the user can fix the cause and + /// save again. + SaveFailed(String), + FlashErrorRow(StatementSource), + CopySelectedRowsAsTsv, + PasteNotSupported, + SelectAllRows, + GoToFirstRow, + GoToLastRow, +} + +#[derive(Debug)] +pub(crate) enum BrowseTabOutput { + /// Something the window keeps for the tab changed: the page, the + /// sort, the page size. + StateChanged, + /// The columns the tab shows changed, which the editor's completion + /// words follow. + ColumnsChanged, + CopyRowAsInsert { + row_position: u32, + }, + CopyToClipboard(String), + ExportResults(ExportRequest), + ShowSelectionAlert { + title: String, + body: String, + }, + ShowToast(String), + DirtyChanged(bool), + ExecuteTransaction(SaveRequest), +} + +impl BrowseModel { + /// The table a browse tab's rows came from, so an export can + /// write them back as `INSERT` statements. + fn sql_target(&self) -> crate::ui::export_dialog::SqlTarget { + crate::ui::export_dialog::SqlTarget { + table: self.table.clone(), + } + } + + fn table_ref(&self) -> TableRef { + TableRef::new(self.schema.clone(), self.table.clone()) + } + + fn export_name(&self) -> String { + qualified(self.schema.as_deref(), &self.table) + } + + /// The tracker context the grid renders through. Copy and export + /// read the same context, so what leaves the tab is what the user + /// is looking at, pending edits included. + fn grid_context(&self) -> TabGridContext { + TabGridContext { + tracker: Some(self.tracker.clone()), + column_widths: self + .connection_id + .map(|connection_id| crate::ui::grid::ColumnWidthBinding { + store: self.column_widths.clone(), + connection_id, + schema: self.schema.clone(), + table: self.table.clone(), + }), + pk_col_indices: self.pk_indices(), + } + } + + fn pk_indices(&self) -> Vec { + self.current_columns + .iter() + .enumerate() + .filter(|(_, c)| c.primary_key) + .map(|(i, _)| i) + .collect() + } + + /// What the paginator's Export button writes. Built from the live + /// grid so it matches the context menu's Export Results row for + /// row; falls back to the fetch itself before the grid exists. + fn export_payload(&self) -> Option { + let current = self.current_result.as_ref()?; + let Some(column_view) = self.current_column_view.as_ref() else { + return Some(current.clone()); + }; + Some(super::grid::export_snapshot( + column_view, + ¤t.columns, + current.truncated, + &self.grid_context(), + )) + } + + /// Resolve a "Set Value" preset against the column it lands in. + /// The grid offers Empty only on free-text columns, so the + /// fallback here is for the keyboard and action-activation paths: + /// an empty string means NULL on a column that takes one, and is + /// refused on a column that does not, exactly as typing an empty + /// value into the cell would be. + fn resolve_cell_preset(&self, preset: CellPreset, col_index: usize, context: EditContext) -> Result { + match preset { + CellPreset::Null => Ok(Value::Null), + CellPreset::Empty => parse_input_for_column("", self.current_columns.get(col_index), context), + } + } + + /// Whether an edit at this position lands in a draft row, which is + /// the one place an empty cell can mean the server's default. + fn edit_context_at(&self, row_position: u32) -> EditContext { + match self + .row_object_at(row_position) + .is_some_and(|row| row.draft_id().is_some()) + { + true => EditContext::InsertDraft, + false => EditContext::UpdateRow, + } + } + + fn table_label(&self) -> String { + qualified(self.schema.as_deref(), &self.table) + } + + fn refresh_pending_bar(&self, count: usize) { + let visible = count > 0; + if visible { + let label = if count == 1 { + crate::i18n::gettext("1 unsaved change") + } else { + crate::i18n::ngettext_f( + "{n} unsaved change", + "{n} unsaved changes", + u32::try_from(count).unwrap_or(u32::MAX), + &[("n", &count.to_string())], + ) + }; + self.pending_label.set_label(&label); + } + self.pending_revealer.set_reveal_child(visible); + self.refresh_banner_visibility(); + } + + /// Apply what a tracker call changed: the draft rows the store gains + /// or loses, then a rebind of each touched row, then the footer. + fn apply_tracker_delta(&self, delta: TrackerDelta, fx: &mut Effects) { + if let Some(store) = self.list_store() { + for change in &delta.visual { + apply_visual_change(&store, change); + } + } + for key in &delta.rows { + if let Some(position) = self.find_row_position_by_key(key) { + self.refresh_row_at(position); + } + } + self.sync_pending_chrome(delta.pending_count, fx); + } + + /// The footer and the tab's dirty flag, which App turns into the + /// title bullet. App hears only real transitions. + fn sync_pending_chrome(&self, count: usize, fx: &mut Effects) { + self.refresh_pending_bar(count); + let dirty = count > 0; + if dirty != self.was_dirty.replace(dirty) { + fx.output(BrowseTabOutput::DirtyChanged(dirty)); + } + } + + fn apply_edit(&self, edit: TrackerEdit, fx: &mut Effects) { + let delta = self.tracker.apply(edit); + self.apply_tracker_delta(delta, fx); + } + + /// A typed or preset value for one cell. A draft keeps the value on + /// its own row; a persisted row keeps it in the tracker. + fn edit_cell(&self, row_position: u32, col: usize, value: Value, fx: &mut Effects) { + if let Some(draft_id) = self.row_object_at(row_position).and_then(|row| row.draft_id()) { + self.apply_edit(TrackerEdit::DraftCell { draft_id, col, value }, fx); + return; + } + let Some((row_key, row)) = self.row_key_at(row_position) else { + return; + }; + let Some(original) = row.get(col).cloned() else { + return; + }; + self.apply_edit( + TrackerEdit::Cell { + row_key, + col, + original, + value, + }, + fx, + ); + } + + /// F5: read the columns again, in case the table changed under the + /// tab, then the page and the count built from them. + fn reload(&mut self) { + self.capture_focus_for_restore(); + self.show_loading(); + self.reload_from_columns(true); + } + + /// Reveal at most ONE banner at a time. Read-only takes priority + /// over no-PK because read-only blocks every kind of edit. + fn refresh_banner_visibility(&self) { + let read_only = self.read_only; + let no_pk = !self.current_columns.is_empty() && !self.current_columns.iter().any(|c| c.primary_key); + self.read_only_banner.set_revealed(read_only); + self.no_pk_banner.set_revealed(!read_only && no_pk); + } + + fn list_store(&self) -> Option { + let selection = self.current_selection.as_ref()?; + selection.model()?.downcast::().ok() + } + + /// Make the view rebind the row at `pos`. + /// + /// The ColumnView's list-item manager compares item pointers and + /// skips the rebind when the item at a position is the same object, + /// and an edit changes cells inside the existing `RowObject`. So a + /// fresh `RowObject` with the post-edit cells is spliced in: one + /// items-changed, no flicker, no scroll jump. + fn refresh_row_at(&self, pos: u32) { + let row_obj = self.row_object_at(pos); + let store = self.list_store(); + let store_pos = match (row_obj.as_ref(), store.as_ref()) { + (Some(r), Some(s)) => s.find(r), + _ => None, + }; + if let (Some(store), Some(store_pos), Some(old)) = (store, store_pos, row_obj) { + let cells = old.cells_clone(); + let replacement = match old.draft_id() { + Some(id) => super::row_object::RowObject::new_draft(id, cells), + None => super::row_object::RowObject::new(cells), + }; + store.splice(store_pos, 1, &[replacement]); + } + } + + fn row_object_at(&self, position: u32) -> Option { + let model = self.current_selection.as_ref()?.model()?; + model.item(position)?.downcast::().ok() + } + + /// Where a row is in the selection model: a draft by its id, a + /// persisted row by its key values. `None` when it is not on this + /// page. + fn find_row_position_by_key(&self, key: &RowKey) -> Option { + use crate::services::change_tracker::KeyValue; + let selection = self.current_selection.as_ref()?; + let model = selection.model()?; + let pk_indices = self.pk_indices(); + let n_items = model.n_items(); + for i in 0..n_items { + let Some(item) = model.item(i) else { continue }; + let Ok(row) = item.downcast::() else { + continue; + }; + let matched = match key { + RowKey::Draft(id) => row.draft_id() == Some(*id), + RowKey::Persisted(target_keys) => { + if row.draft_id().is_some() || pk_indices.is_empty() { + false + } else { + let row_keys: Vec = pk_indices + .iter() + .map(|&col_idx| tablepro_core::value::ValueIdentity::new(row.cell_value(col_idx))) + .collect(); + row_keys == *target_keys + } + } + }; + if matched { + return Some(i); + } + } + None + } + + /// Scroll to the row behind a failed statement and flash it. A row + /// paged, sorted or filtered away is not found, and the alert alone + /// tells the user which statement failed. + fn flash_error_row(&self, source: &StatementSource) { + let key = match source { + StatementSource::Insert { draft_id } => RowKey::Draft(*draft_id), + StatementSource::Update { row_key } | StatementSource::Delete { row_key } => row_key.clone(), + }; + let Some(position) = self.find_row_position_by_key(&key) else { + return; + }; + if let Some(selection) = self.current_selection.as_ref() { + selection.select_item(position, true); + } + if let Some(cv) = self.current_column_view.as_ref() { + cv.scroll_to( + position, + None, + gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, + None, + ); + } + let generation = self.tracker.flash_error_row(key); + self.refresh_row_at(position); + let tracker = self.tracker.clone(); + let selection_for_clear = self.current_selection.clone(); + // Matches the length of the flash animation in the stylesheet. + glib::timeout_add_local_once(std::time::Duration::from_millis(1800), move || { + if !tracker.clear_error_row_if(generation) { + return; + } + if let Some(selection) = selection_for_clear + && let Some(model) = selection.model() + && let Some(row_obj) = model + .item(position) + .and_then(|o| o.downcast::().ok()) + && let Some(store) = model.downcast::().ok() + && let Some(store_pos) = store.find(&row_obj) + { + store.items_changed(store_pos, 1, 1); + } + }); + } + + /// Snapshot the currently focused/first-selected row's identity + /// before an operation that reloads the page (sort flip, save, F5), + /// so the view re-anchors to the same row after the new page. + fn capture_focus_for_restore(&self) { + // A capture still pending (sort and save in quick succession) + // is kept: the first visible focus should anchor to where the + // user was before the chain started. + if self.pending_focus_restore.borrow().is_some() { + return; + } + let Some(selection) = self.current_selection.as_ref() else { + return; + }; + let bitset = selection.selection(); + if bitset.size() == 0 { + return; + } + let pos = bitset.nth(0); + let Some(model) = selection.model() else { return }; + let Some(item) = model.item(pos) else { return }; + let Ok(row) = item.downcast::() else { + return; + }; + let key = if let Some(draft_id) = row.draft_id() { + Some(RowKey::Draft(draft_id)) + } else { + let pk_indices = self.pk_indices(); + if pk_indices.is_empty() { + None + } else { + let pk_values: Vec = pk_indices.iter().map(|&i| row.cell_value(i)).collect(); + RowKey::from_pk_values(&pk_values) + } + }; + *self.pending_focus_restore.borrow_mut() = key; + } + + /// Scroll + focus + select the freshly-prepended draft row, then + /// open its first editable cell for input. Queued on idle so the + /// inner-stack flip (empty → grid) has time to allocate the + /// ScrolledWindow's adjustments before `cv.scroll_to(...)` runs: + /// in the same tick GTK aborts with + /// `gtk_list_base_update_adjustments: bounds.y == 0`. + fn focus_inserted_draft(&self) { + let Some(cv) = self.current_column_view.clone() else { + return; + }; + let selection = self.current_selection.clone(); + glib::idle_add_local_once(move || { + // The column view may be detached by now (F5 between the + // insert and this idle). The row was inserted; only the + // auto-edit is missed. + if cv.root().is_none() { + return; + } + // New drafts always land at position 0 (prepended). + if let Some(sel) = selection.as_ref() { + sel.select_item(0, true); + } + cv.scroll_to( + 0, + None, + gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, + None, + ); + let Some(window) = cv.root().and_then(|r| r.dynamic_cast::().ok()) else { + return; + }; + let Some(focused) = gtk::prelude::GtkWindowExt::focus(&window) else { + return; + }; + if let Ok(label) = focused.dynamic_cast::() { + if label.text().as_str() == super::grid::editable_null_sentinel() { + label.set_text(""); + } + label.start_editing(); + } + }); + } + + /// Select the row captured before the reload again, if it is still on + /// the page. The capture is used up either way, so it cannot steer a + /// later reload. + fn restore_focused_row(&self) { + let Some(key) = self.pending_focus_restore.borrow_mut().take() else { + return; + }; + let Some(position) = self.find_row_position_by_key(&key) else { + return; + }; + if let Some(selection) = self.current_selection.as_ref() { + selection.select_item(position, true); + } + if let Some(cv) = self.current_column_view.as_ref() { + cv.scroll_to(position, None, gtk::ListScrollFlags::FOCUS, None); + } + } + + /// Build the `ColumnView` once both the columns and the current + /// page are in. Until then the stack stays on the loading page, so + /// the user cannot edit cells whose editability is not known yet. + fn render_grid_if_ready(&mut self, fx: &mut Effects) { + let Some(result) = self.current_result.clone() else { + return; + }; + if self.current_columns.is_empty() { + return; + } + + // Same columns as the last render (sort flips, page changes, + // save reloads): keep the `ColumnView` and only refresh the + // `ListStore`. + if self.column_view_matches_current_columns() + && let Some(store) = self.list_store() + { + self.refresh_grid_data(&result, &store); + self.refresh_grid_chrome(&result); + self.restore_focused_row(); + fx.output(BrowseTabOutput::StateChanged); + return; + } + + clear_box(&self.grid_holder); + let tab_ctx = self.grid_context(); + let (column_view, selection) = build_column_view( + &result, + &self.current_columns, + self.grid_sender.clone(), + !self.read_only, + self.current_sort, + Some(self.grid_sender.clone()), + tab_ctx, + ); + self.current_selection = Some(selection); + self.current_column_view = Some(column_view.clone()); + self.rendered_column_count.set(self.current_columns.len()); + + // The selection model is new per rebuild, so the previous + // handler goes with the old one. + let selection_label_for_signal = self.selection_label.clone(); + if let Some(sel) = self.current_selection.as_ref() { + sel.connect_selection_changed(move |sel, _, _| { + let n = u32::try_from(sel.selection().size()).unwrap_or(u32::MAX); + update_selection_chrome(&selection_label_for_signal, n); + }); + update_selection_chrome(&self.selection_label, 0); + } + + // Pending draft rows survive page changes, sort flips and F5: + // the tracker holds them, and the rebuilt store would not. + self.reprepend_drafts(); + + let scrolled = gtk::ScrolledWindow::builder() + .child(&column_view) + .hexpand(true) + .vexpand(true) + .build(); + self.grid_holder.append(&scrolled); + self.refresh_grid_chrome(&result); + self.restore_focused_row(); + fx.output(BrowseTabOutput::StateChanged); + } + + fn column_view_matches_current_columns(&self) -> bool { + self.current_column_view.is_some() && self.rendered_column_count.get() == self.current_columns.len() + } + + /// New rows under the same columns. The drafts go back on top so + /// they survive the swap. + fn refresh_grid_data(&self, result: &QueryResult, store: >k::gio::ListStore) { + store.remove_all(); + for row in &result.rows { + store.append(&super::row_object::RowObject::new(row.clone())); + } + self.reprepend_drafts(); + } + + /// Paginator label, button sensitivity and stack child: chrome that + /// depends on the result but not the column structure. + fn refresh_grid_chrome(&self, result: &QueryResult) { + self.refresh_crud_buttons(); + self.update_paginator_label(); + let on_first_page = self.current_offset == 0; + self.first_button.set_sensitive(!on_first_page); + self.prev_button.set_sensitive(!on_first_page); + let n_rows = result.rows.len() as u64; + self.next_button.set_sensitive(n_rows == self.page_size); + // Last only enables when the total is known and the tab is not + // already on the last page. + let last_target = self + .current_total_rows + .filter(|t| *t > 0) + .map(|t| (t - 1) / self.page_size * self.page_size); + self.last_button + .set_sensitive(last_target.is_some_and(|target| self.current_offset != target)); + + self.refresh_inner_stack_visibility(); + self.suppress_combo_emit.set(false); + } + + /// Switch the inner stack to the grid once the first page has + /// loaded, and stay there whatever the row count: an empty-list + /// status page hid the column view, and inserting a draft into it + /// then scrolled a view with no allocated adjustments. + fn refresh_inner_stack_visibility(&self) { + if self.current_result.is_some() { + self.inner_stack.set_visible_child_name("grid"); + } + } + + fn reprepend_drafts(&self) { + let Some(store) = self.list_store() else { + return; + }; + for draft in self.tracker.drafts() { + store.insert( + 0, + &super::row_object::RowObject::new_draft(draft.draft_id, draft.values), + ); + } + } + + /// The (RowKey, current_values) pair for a row at a selection-model + /// position, which counts prepended drafts. `None` for drafts, for + /// tables with no PK, and out of range. + fn row_key_at(&self, row_position: u32) -> Option<(RowKey, Vec)> { + let row_obj = self.row_object_at(row_position)?; + if row_obj.draft_id().is_some() { + return None; + } + build_persisted_row_key(row_obj.cells_clone(), &self.pk_indices()) + } + + fn has_primary_key(&self) -> bool { + self.current_columns.iter().any(|c| c.primary_key) + } + + fn refresh_crud_buttons(&self) { + let has_columns = !self.current_columns.is_empty(); + let has_pk = self.has_primary_key(); + if self.read_only { + self.insert_button.set_visible(false); + return; + } + self.insert_button.set_visible(true); + // No-PK tables don't get inline editing because RowKey can't be + // formed without a PK. Disable instead of hide so the affordance + // stays discoverable; the tooltip explains the gate. + self.insert_button.set_sensitive(has_columns && has_pk); + if has_columns && !has_pk { + self.insert_button.set_tooltip_text(Some(&crate::i18n::gettext( + "This table has no primary key. Inline editing is disabled.", + ))); + } else { + self.insert_button + .set_tooltip_text(Some(&crate::i18n::gettext("Insert row (Ctrl+N)"))); + } + self.refresh_banner_visibility(); + } + + fn refresh_filter_chrome(&self) { + let n = self.current_filter.len(); + if n == 0 { + self.filter_badge.set_visible(false); + self.filter_badge.set_label(""); + self.filter_button + .set_tooltip_text(Some(&crate::i18n::gettext("Filter rows (Ctrl+F)"))); + } else { + self.filter_badge.set_label(&n.to_string()); + self.filter_badge.set_visible(true); + self.filter_button.set_tooltip_text(Some(&crate::i18n::ngettext_f( + "{n} filter rule active. Click to edit", + "{n} filter rules active. Click to edit", + u32::try_from(n).unwrap_or(u32::MAX), + &[("n", &n.to_string())], + ))); + } + } + + fn update_paginator_label(&self) { + let Some(result) = self.current_result.as_ref() else { + self.paginator_label.set_label(""); + return; + }; + let n_rows = result.rows.len(); + if n_rows == 0 { + // Reachable when the user navigated past the end of a + // table that shrank in another session, before the count + // clamps the offset back. + self.paginator_label + .set_label(&crate::i18n::gettext("No rows on this page")); + return; + } + let start = self.current_offset + 1; + let end = self.current_offset + n_rows as u64; + let start_s = format_thousands(start); + let end_s = format_thousands(end); + let label = match self.current_total_rows { + Some(total) => crate::i18n::gettext_f( + "Rows {start} – {end} of {total}", + &[ + ("start", &start_s), + ("end", &end_s), + ("total", &format_thousands(total)), + ], + ), + None => crate::i18n::gettext_f("Rows {start} – {end}", &[("start", &start_s), ("end", &end_s)]), + }; + self.paginator_label.set_label(&label); + } + + fn show_loading(&self) { + self.loading_page.set_description(Some(&crate::i18n::gettext_f( + "Fetching rows from {table}", + &[("table", &self.table_label())], + ))); + self.inner_stack.set_visible_child_name("loading"); + } + + /// Show why no rows could be read. The page and the count from + /// before are dropped, so a later reload does not render against + /// them before its own page arrives. + fn show_error(&mut self, message: &str) { + self.current_result = None; + self.current_total_rows = None; + self.paginator_label.set_label(""); + self.first_button.set_sensitive(false); + self.prev_button.set_sensitive(false); + self.next_button.set_sensitive(false); + self.last_button.set_sensitive(false); + self.error_page.set_description(Some(message)); + self.inner_stack.set_visible_child_name("error"); + } + + fn on_rows_loaded(&mut self, offset: u64, mut result: QueryResult, fx: &mut Effects) { + self.current_offset = offset; + // A zero-row page has no columns of its own; the catalog's list + // lets the headers render anyway. + if result.columns.is_empty() && !self.current_columns.is_empty() { + result.columns = result_columns(&self.current_columns); + } + self.current_result = Some(result); + self.render_grid_if_ready(fx); + } + + fn on_columns_loaded(&mut self, columns: Vec, fx: &mut Effects) { + self.current_columns = columns.clone(); + if let Some(result) = self.current_result.as_mut() + && result.columns.is_empty() + { + result.columns = result_columns(&columns); + } + self.refresh_crud_buttons(); + // Operator lists narrow per type, so the strip rebuilds against + // the new columns. + if let Some(strip) = self.filter_strip.as_ref() { + strip.update_columns(columns); + } + fx.output(BrowseTabOutput::ColumnsChanged); + self.render_grid_if_ready(fx); + } + + fn on_row_count_loaded(&mut self, count: u64, fx: &mut Effects) { + self.current_total_rows = Some(count); + // A saved offset past the end goes back to the last full page, + // for rows another session deleted since. + if self.current_offset > 0 && count > 0 && self.current_offset >= count { + let last_page_offset = count.saturating_sub(1) / self.page_size * self.page_size; + if last_page_offset != self.current_offset { + self.current_offset = last_page_offset; + self.fetch(BrowseRequestKind::Page, fx); + fx.output(BrowseTabOutput::StateChanged); + } + } + self.update_paginator_label(); + } + + fn update(&mut self, msg: BrowseTabInput, fx: &mut Effects) { + match msg { + BrowseTabInput::Start => { + self.refresh_crud_buttons(); + self.refresh_pending_bar(0); + // A saved filter shows on the button before the first page + // returns. + self.refresh_filter_chrome(); + self.reload_from_columns(false); + } + BrowseTabInput::Command(command) => self.on_command(command, fx), + BrowseTabInput::LinkChanged(generation) => self.on_link_changed(generation, fx), + BrowseTabInput::SchemaChanged => { + self.capture_focus_for_restore(); + self.reload_from_columns(false); + } + BrowseTabInput::Refresh => self.reload(), + BrowseTabInput::DiscardAndRefresh => { + let delta = self.tracker.discard(); + self.apply_tracker_delta(delta, fx); + self.reload(); + } + BrowseTabInput::ClearSelection => { + let Some(sel) = self.current_selection.as_ref() else { + return; + }; + // Only a true multi-row selection; clearing a single + // focused row strands the focus indicator. + if sel.selection().size() < 2 { + return; + } + sel.unselect_all(); + } + BrowseTabInput::ToggleFilterStrip => { + if let Some(strip) = self.filter_strip.as_ref() { + strip.toggle(); + } + } + BrowseTabInput::FilterApplied(set) => { + // The same rules again would only duplicate F5. + if set == self.current_filter { + return; + } + self.current_filter = set.clone(); + if let Some(conn_id) = self.connection_id { + self.filter_settings + .set_filters(conn_id, self.schema.as_deref(), &self.table, set.clone()); + } + // Filtered counts shift, so back to page 1. + self.current_offset = 0; + self.refresh_filter_chrome(); + if let Some(strip) = self.filter_strip.as_ref() { + strip.update_filter(set); + } + self.fetch(BrowseRequestKind::Page, fx); + self.fetch(BrowseRequestKind::Count, fx); + fx.output(BrowseTabOutput::StateChanged); + } + BrowseTabInput::FirstPage => { + if self.current_offset > 0 { + self.current_offset = 0; + self.fetch(BrowseRequestKind::Page, fx); + fx.output(BrowseTabOutput::StateChanged); + } + } + BrowseTabInput::PrevPage => { + if self.current_offset >= self.page_size { + self.current_offset -= self.page_size; + self.fetch(BrowseRequestKind::Page, fx); + fx.output(BrowseTabOutput::StateChanged); + } + } + BrowseTabInput::NextPage => { + self.current_offset += self.page_size; + self.fetch(BrowseRequestKind::Page, fx); + fx.output(BrowseTabOutput::StateChanged); + } + BrowseTabInput::LastPage => { + // The button stays disabled until the count is known. + let Some(total) = self.current_total_rows else { + return; + }; + if total == 0 { + return; + } + let last_page_offset = (total - 1) / self.page_size * self.page_size; + if self.current_offset != last_page_offset { + self.current_offset = last_page_offset; + self.fetch(BrowseRequestKind::Page, fx); + fx.output(BrowseTabOutput::StateChanged); + } + } + BrowseTabInput::SortChanged { col_idx, ascending } => { + // The sorter notifies twice for one click on a new column + // (column, then order), with the same final pair. + let next = (col_idx, ascending); + if self.current_sort == Some(next) { + return; + } + self.current_sort = Some(next); + self.current_offset = 0; + self.capture_focus_for_restore(); + self.fetch(BrowseRequestKind::Page, fx); + fx.output(BrowseTabOutput::StateChanged); + } + BrowseTabInput::PageSizeChanged(size) => { + if self.suppress_combo_emit.get() || self.page_size == size { + return; + } + self.page_size = size; + self.current_offset = 0; + self.fetch(BrowseRequestKind::Page, fx); + fx.output(BrowseTabOutput::StateChanged); + } + BrowseTabInput::DuplicateRow { row_position } => { + if self.current_columns.is_empty() { + return; + } + // Through the live RowObject: the position counts the + // user's sort and any prepended drafts, which the fetched + // rows do not. + let Some(source) = self.row_object_at(row_position) else { + return; + }; + let source_cells = source.cells_clone(); + // The duplicate is a new row, so values the database owns + // (key, identity, generated) are left to it. + let values: Vec = self + .current_columns + .iter() + .enumerate() + .map(|(i, col)| { + if col.primary_key || col.is_auto_increment || col.is_generated { + Value::Null + } else { + source_cells.get(i).cloned().unwrap_or(Value::Null) + } + }) + .collect(); + self.apply_edit(TrackerEdit::Insert { values }, fx); + self.refresh_inner_stack_visibility(); + fx.input(BrowseTabInput::FocusInsertedDraft); + } + BrowseTabInput::InsertRow => { + if self.current_columns.is_empty() { + return; + } + let values: Vec = self.current_columns.iter().map(|_| Value::Null).collect(); + self.apply_edit(TrackerEdit::Insert { values }, fx); + self.refresh_inner_stack_visibility(); + fx.input(BrowseTabInput::FocusInsertedDraft); + } + BrowseTabInput::DeleteSelectedRow => self.delete_selected_rows(fx), + BrowseTabInput::GridCellEdited { + row_position, + col_index, + new_value, + } => { + // A column that holds line breaks keeps them; everywhere + // else a pasted newline would reach the SQL layer. + let keeps_line_breaks = self + .current_columns + .get(col_index) + .is_some_and(|c| c.column_type.kind.accepts_line_breaks()); + let normalized = match keeps_line_breaks { + true => new_value, + false => normalize_single_line_input(&new_value), + }; + let col = self.current_columns.get(col_index); + let new = match parse_input_for_column(&normalized, col, self.edit_context_at(row_position)) { + Ok(v) => v, + Err(message) => { + // The cell still shows what was typed; a rebind + // puts the value back. + fx.output(BrowseTabOutput::ShowToast(message)); + self.refresh_row_at(row_position); + return; + } + }; + self.edit_cell(row_position, col_index, new, fx); + } + BrowseTabInput::GridSetCellValue { + row_position, + col_index, + preset, + } => { + let value = match self.resolve_cell_preset(preset, col_index, self.edit_context_at(row_position)) { + Ok(value) => value, + Err(message) => { + fx.output(BrowseTabOutput::ShowToast(message)); + return; + } + }; + self.edit_cell(row_position, col_index, value, fx); + } + BrowseTabInput::GridShowToast(message) => { + fx.output(BrowseTabOutput::ShowToast(message)); + } + BrowseTabInput::GridExportResults(mut result) => { + // The grid owns the rows it shows; whether the fetch was + // cut short belongs to the tab. + result.truncated = self.current_result.as_ref().is_some_and(|r| r.truncated); + fx.output(BrowseTabOutput::ExportResults(ExportRequest { + result, + name: self.export_name(), + target: Some(self.sql_target()), + })); + } + BrowseTabInput::ExportCurrentPage => { + let Some(result) = self.export_payload() else { + fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext("Nothing to export"))); + return; + }; + fx.output(BrowseTabOutput::ExportResults(ExportRequest { + result, + name: self.export_name(), + target: Some(self.sql_target()), + })); + } + BrowseTabInput::GridDeleteRowAt { row_position } => { + let Some((row_key, original_values)) = self.row_key_at(row_position) else { + return; + }; + self.apply_edit( + TrackerEdit::Delete { + row_key, + original_values, + }, + fx, + ); + } + BrowseTabInput::GridCopyRowAsInsert { row_position } => { + fx.output(BrowseTabOutput::CopyRowAsInsert { row_position }); + } + BrowseTabInput::GridCopyToClipboard(text) => { + fx.output(BrowseTabOutput::CopyToClipboard(text)); + } + BrowseTabInput::CopySelectedRowsAsTsv => self.copy_selected_rows(fx), + BrowseTabInput::PasteNotSupported => { + fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( + "Pasting rows isn't supported yet", + ))); + } + BrowseTabInput::SelectAllRows => { + if let Some(selection) = self.current_selection.as_ref() + && selection.n_items() > 0 + { + selection.select_all(); + } + } + BrowseTabInput::GoToFirstRow => self.scroll_to_row(|_| Some(0)), + BrowseTabInput::GoToLastRow => self.scroll_to_row(|n| n.checked_sub(1)), + BrowseTabInput::CommitSave => { + let columns = self.current_columns.clone(); + match self.tracker.change_set(&columns) { + Ok(changes) if !changes.changes.is_empty() => { + // Both buttons stay off while the save runs, so a + // double click cannot send two. + self.save_button.set_sensitive(false); + self.discard_button.set_sensitive(false); + self.capture_focus_for_restore(); + fx.output(BrowseTabOutput::ExecuteTransaction(SaveRequest { + table: self.table_ref(), + columns, + changes, + })); + } + Ok(_) => self.refresh_pending_bar(0), + Err(e) => { + fx.output(BrowseTabOutput::ShowSelectionAlert { + title: crate::i18n::gettext("Cannot save"), + body: format!("{e}"), + }); + } + } + } + BrowseTabInput::DiscardAll => { + let delta = self.tracker.discard(); + self.apply_tracker_delta(delta, fx); + self.fetch(BrowseRequestKind::Page, fx); + } + BrowseTabInput::MarkRowsDeleted { rows, drafts } => { + for (row_key, original_values) in rows { + self.apply_edit( + TrackerEdit::Delete { + row_key, + original_values, + }, + fx, + ); + } + for draft_id in drafts { + self.apply_edit(TrackerEdit::DiscardDraft { draft_id }, fx); + } + // A bulk action ends the selection it acted on. + if let Some(selection) = self.current_selection.as_ref() { + selection.unselect_all(); + } + } + BrowseTabInput::SaveCompleted => { + // The reload replaces every row; dropping the saved + // drafts from the store first would blank them until it + // lands. + let delta = self.tracker.discard(); + self.sync_pending_chrome(delta.pending_count, fx); + self.save_button.set_sensitive(true); + self.discard_button.set_sensitive(true); + self.fetch(BrowseRequestKind::Page, fx); + self.fetch(BrowseRequestKind::Count, fx); + } + BrowseTabInput::SaveFailed(message) => { + self.save_button.set_sensitive(true); + self.discard_button.set_sensitive(true); + fx.output(BrowseTabOutput::ShowSelectionAlert { + title: crate::i18n::gettext("Save failed"), + body: message, + }); + } + BrowseTabInput::FlashErrorRow(source) => self.flash_error_row(&source), + BrowseTabInput::FocusInsertedDraft => self.focus_inserted_draft(), + BrowseTabInput::Undo => { + if let Some(delta) = self.tracker.undo() { + self.apply_tracker_delta(delta, fx); + } + } + BrowseTabInput::Redo => { + if let Some(delta) = self.tracker.redo() { + self.apply_tracker_delta(delta, fx); + } + } + } + } + + fn scroll_to_row(&self, target: impl FnOnce(u32) -> Option) { + let Some(cv) = self.current_column_view.as_ref() else { + return; + }; + let n = self.current_selection.as_ref().map(|s| s.n_items()).unwrap_or(0); + let Some(position) = target(n).filter(|position| *position < n) else { + return; + }; + cv.scroll_to( + position, + None, + gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, + None, + ); + } + + /// Mark the selected rows for deletion: persisted rows go to the + /// tracker, drafts are dropped. Nothing leaves the tab until Save. + fn delete_selected_rows(&self, fx: &mut Effects) { + let Some(selection) = self.current_selection.as_ref() else { + return; + }; + let positions = super::grid::selected_positions(selection); + if positions.is_empty() { + return; + } + let pk_indices = self.pk_indices(); + // Resolved through the selection model: its positions count the + // prepended drafts, which the fetched rows do not. + let Some(model) = selection.model() else { + return; + }; + let mut snapshot: Vec<(RowKey, Vec)> = Vec::new(); + let mut draft_ids: Vec = Vec::new(); + let mut had_persisted_row = false; + for pos in &positions { + let Some(item) = model.item(*pos) else { continue }; + let Ok(row_obj) = item.downcast::() else { + continue; + }; + if let Some(draft_id) = row_obj.draft_id() { + draft_ids.push(draft_id); + continue; + } + had_persisted_row = true; + if let Some(pair) = build_persisted_row_key(row_obj.cells_clone(), &pk_indices) { + snapshot.push(pair); + } + } + // A selection of drafts only needs no key. + if had_persisted_row && pk_indices.is_empty() { + fx.output(BrowseTabOutput::ShowSelectionAlert { + title: crate::i18n::gettext("Cannot delete"), + body: crate::i18n::gettext("This table has no primary key, so editing is disabled."), + }); + return; + } + if snapshot.is_empty() && draft_ids.is_empty() { + return; + } + let count = snapshot.len() + draft_ids.len(); + if count < BULK_DELETE_CONFIRM_THRESHOLD { + fx.input(BrowseTabInput::MarkRowsDeleted { + rows: snapshot, + drafts: draft_ids, + }); + return; + } + let title = crate::i18n::ngettext_f( + "Delete {n} row?", + "Delete {n} rows?", + u32::try_from(count).unwrap_or(u32::MAX), + &[("n", &count.to_string())], + ); + let body = + crate::i18n::gettext("These rows will be marked for deletion. They aren't removed until you click Save."); + let dialog = adw::AlertDialog::new(Some(&title), Some(&body)); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("delete", &crate::i18n::gettext("Delete")); + dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let pending = RefCell::new(Some((snapshot, draft_ids))); + let tab = self.this.clone(); + dialog.connect_response(None, move |dlg, response| { + dlg.close(); + if response == "delete" + && let Some((rows, drafts)) = pending.borrow_mut().take() + && let Some(tab) = tab.upgrade() + { + tab.send(BrowseTabInput::MarkRowsDeleted { rows, drafts }); + } + }); + dialog.present(Some(&self.inner_stack)); + } + + /// Same renderer as the context menu's Copy as > Rows: one + /// selection cannot produce two different clipboard payloads. + fn copy_selected_rows(&self, fx: &mut Effects) { + let Some(selection) = self.current_selection.as_ref() else { + return; + }; + let positions = super::grid::selected_positions(selection); + if positions.is_empty() { + return; + } + let Some(model) = selection.model() else { + return; + }; + let ctx = self.grid_context(); + let rows: Vec> = positions + .iter() + .filter_map(|pos| model.item(*pos)) + .filter_map(|item| item.downcast::().ok()) + .map(|row| ctx.effective_cells(&row)) + .collect(); + if rows.is_empty() { + return; + } + match tablepro_core::export::render_tsv(&result_columns(&self.current_columns), &rows, false) { + Ok(tsv) => fx.output(BrowseTabOutput::CopyToClipboard(tsv)), + Err(error) => fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext_f( + "Couldn't copy the selection: {error}", + &[("error", &error.to_string())], + ))), + } + } +} + +fn qualified(schema: Option<&str>, table: &str) -> String { + match schema { + Some(s) => format!("{s}.{table}"), + None => table.to_owned(), + } +} + +fn apply_visual_change(store: >k::gio::ListStore, change: &VisualChange) { + use super::row_object::RowObject; + let draft_at = |draft_id: u64| { + (0..store.n_items()).find_map(|position| { + let row = store.item(position).and_downcast::()?; + (row.draft_id() == Some(draft_id)).then_some((position, row)) + }) + }; + match change { + VisualChange::InsertDraft { draft_id, values } => { + store.insert(0, &RowObject::new_draft(*draft_id, values.clone())); + } + VisualChange::RemoveDraft { draft_id } => { + if let Some((position, _)) = draft_at(*draft_id) { + store.remove(position); + } + } + VisualChange::SetDraftCell { draft_id, col, value } => { + if let Some((_, row)) = draft_at(*draft_id) { + row.set_cell(*col, value.clone()); + } + } + } +} + +fn clear_box(b: >k::Box) { + while let Some(child) = b.first_child() { + b.remove(&child); + } +} + +/// Collapse newlines / carriage returns to spaces, then squash any +/// resulting consecutive whitespace runs to a single space. Applied +/// at cell-edit commit time for non-JSON columns so a multi-line +/// clipboard paste into a single-line cell never reaches the SQL +/// layer with embedded `\n`. +fn normalize_single_line_input(text: &str) -> String { + let replaced: String = text + .chars() + .map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }) + .collect(); + replaced.split_whitespace().collect::>().join(" ") +} + +/// Build a `(RowKey, cells)` pair for a persisted row given its full +/// cell slice and the table's PK column indices. Returns `None` when +/// pk_indices is empty (no PK), any index is out of range, or +/// `RowKey::from_pk_values` rejects the values. +fn build_persisted_row_key(cells: Vec, pk_indices: &[usize]) -> Option<(RowKey, Vec)> { + let pk_values: Vec = pk_indices + .iter() + .map(|&i| cells.get(i).cloned()) + .collect::>()?; + let key = RowKey::from_pk_values(&pk_values)?; + Some((key, cells)) +} + +/// Update the selection-count badge in response to a `MultiSelection` +/// change. Hidden for 0–1 rows; shows "{n} selected · press Delete to +/// remove" once the user multi-selects, so the affordance stays +/// discoverable without a toolbar Delete button. +fn update_selection_chrome(label: >k::Label, n: u32) { + if n <= 1 { + label.set_visible(false); + return; + } + let count = n.to_string(); + label.set_label(&crate::i18n::gettext_f( + "{n} selected · press Delete to remove", + &[("n", &count)], + )); + label.set_visible(true); +} + +/// The value the user typed, as the column's own type. +/// +/// Parsing against the typed column rather than a type-name string is +/// what makes an out-of-range number a message beside the cell instead +/// of a failed transaction. What an empty cell means is core's rule: +/// the empty string where the column holds one, the server's default +/// in a draft row, NULL where the column takes it, and a refusal +/// otherwise. +fn parse_input_for_column(text: &str, col: Option<&ColumnInfo>, context: EditContext) -> Result { + let Some(col) = col else { + return Ok(Value::Text(text.to_string())); + }; + match tablepro_core::edit::parse_edit_text(text, col, context) { + Ok(CellInput::Value(value)) => Ok(value), + // A draft holds NULL for a cell left to the server; the save + // leaves that column out of the INSERT. + Ok(CellInput::Default) => Ok(Value::Null), + Err(EditParseError::Required) => Err(crate::i18n::gettext("Field is required")), + Err(error) => Err(error.to_string()), + } +} + +/// Format a positive integer with thousands separators (1000 → 1,000). +/// Used for page-size dropdown labels so they read naturally instead +/// of the abbreviated "1 K" / "5 K" form. +fn format_thousands(n: u64) -> String { + let s = n.to_string(); + let bytes = s.as_bytes(); + let len = bytes.len(); + let mut out = String::with_capacity(len + len / 3); + for (i, b) in bytes.iter().enumerate() { + if i > 0 && (len - i).is_multiple_of(3) { + out.push(','); + } + out.push(*b as char); + } + out +} + +/// The result-column view of a catalogue column list, for a result the +/// grid renders from the schema it already has. +fn result_columns(columns: &[tablepro_core::ColumnInfo]) -> std::sync::Arc<[tablepro_core::column::ResultColumn]> { + columns.iter().map(|column| column.result_column()).collect() +} diff --git a/linux/crates/app/src/ui/browse_tab/page_reply.rs b/linux/crates/app/src/ui/browse_tab/page_reply.rs new file mode 100644 index 0000000000..a70e193335 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/page_reply.rs @@ -0,0 +1,20 @@ +use tablepro_core::QueryResult; +use tablepro_core::meta::PagePosition; + +/// One page of rows and where in the table it starts. +#[derive(Debug, Clone)] +pub(crate) struct PageReply { + pub position: PagePosition, + pub result: QueryResult, +} + +impl PageReply { + /// The row offset the page starts at, for the paginator. + pub fn offset(&self) -> Option { + match self.position { + PagePosition::First => Some(0), + PagePosition::Offset(offset) => Some(offset), + PagePosition::AfterKey(_) | PagePosition::BeforeKey(_) | PagePosition::CtidWindow { .. } => None, + } + } +} diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs new file mode 100644 index 0000000000..8cea84a95b --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -0,0 +1,554 @@ +use std::sync::Arc; +use std::time::Duration; + +use gtk4::prelude::*; +use tablepro_core::column::{ColumnDefault, ColumnType}; +use tablepro_core::edit::EditContext; +use tablepro_core::filter::{FilterOp, FilterRule}; +use tablepro_core::meta::RowIdentity; +use tablepro_core::{ColumnInfo, DriverError, FilterSet, LossPhase, QueryResult, Value}; +use tablepro_session::Session; +use tablepro_session::fakes::FakeEstablish; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeReply}; + +use super::{ + BrowseTab, BrowseTabInit, BrowseTabInput, apply_visual_change, build_persisted_row_key, format_thousands, + parse_input_for_column, +}; +use crate::services::change_tracker::RowKey; +use crate::services::tracker_delta::VisualChange; +use crate::test_support::{FakeSession, drain_main_context, wait_until}; +use crate::ui::row_object::RowObject; + +const WAIT: Duration = Duration::from_secs(10); + +/// A column carrying the server's spelling and the kind it means. +#[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" +)] +fn col(data_type: &str, nullable: bool) -> ColumnInfo { + let kind = tablepro_core::column::classify_type_name(data_type); + ColumnInfo { + name: "x".into(), + column_type: tablepro_core::column::ColumnType { + name: data_type.to_owned(), + dynamic_storage: tablepro_core::column::has_dynamic_storage(&kind), + kind, + catalog: Some(tablepro_core::column::CatalogType { + declaration: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), + cast_target: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), + }), + read_form: tablepro_core::column::ReadForm::Native, + }, + nullable, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: tablepro_core::column::ColumnDefault::None, + comment: None, + } +} + +#[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" +)] +fn col_with_default(data_type: &str, default: &str) -> ColumnInfo { + let mut c = col(data_type, false); + c.default = tablepro_core::column::ColumnDefault::Expression( + tablepro_core::column::SqlExpression::from_catalog_text(default), + ); + c +} + +#[test] +fn format_thousands_handles_common_page_sizes() { + assert_eq!(format_thousands(100), "100"); + assert_eq!(format_thousands(500), "500"); + assert_eq!(format_thousands(1_000), "1,000"); + assert_eq!(format_thousands(5_000), "5,000"); + assert_eq!(format_thousands(10_000), "10,000"); + assert_eq!(format_thousands(1_000_000), "1,000,000"); +} + +#[test] +fn format_thousands_handles_edges() { + assert_eq!(format_thousands(0), "0"); + assert_eq!(format_thousands(1), "1"); + assert_eq!(format_thousands(999), "999"); +} + +#[test] +fn clearing_a_text_cell_stores_the_empty_string() { + let cleared = + parse_input_for_column("", Some(&col("text", true)), EditContext::UpdateRow).expect("an empty string"); + + assert_eq!(cleared, Value::Text(String::new())); +} + +#[test] +fn clearing_a_column_with_no_empty_value_yields_null() { + let cleared = parse_input_for_column("", Some(&col("timestamp", true)), EditContext::UpdateRow).expect("a null"); + + assert!(matches!(cleared, Value::Null)); +} + +#[test] +fn empty_on_not_null_with_default_is_left_to_the_server_only_in_a_draft() { + let column = col_with_default("timestamp", "now()"); + + let draft = parse_input_for_column("", Some(&column), EditContext::InsertDraft).unwrap(); + assert!(matches!(draft, Value::Null), "a draft leaves it to the default"); + + let existing = parse_input_for_column("", Some(&column), EditContext::UpdateRow); + assert!(existing.is_err(), "an UPDATE cannot ask for the default: {existing:?}"); +} + +#[test] +fn empty_on_a_required_column_with_no_empty_value_is_rejected() { + let r = parse_input_for_column("", Some(&col("integer", false)), EditContext::UpdateRow); + + assert!(r.is_err()); + assert!(r.as_ref().is_err_and(|message| message.contains("required")), "{r:?}"); +} + +#[test] +fn parses_int_decimal_float_bool() { + assert!(matches!( + parse_input_for_column("42", Some(&col("integer", false)), EditContext::UpdateRow).unwrap(), + Value::Int(42) + )); + assert!(matches!( + parse_input_for_column("3.14", Some(&col("real", false)), EditContext::UpdateRow).unwrap(), + Value::Float32(_) + )); + assert!(matches!( + parse_input_for_column("99.99", Some(&col("decimal(10,2)", false)), EditContext::UpdateRow).unwrap(), + Value::Decimal(_) + )); + assert!(matches!( + parse_input_for_column("yes", Some(&col("boolean", false)), EditContext::UpdateRow).unwrap(), + Value::Bool(true) + )); + // MySQL spells boolean as tinyint(1), so it parses as the integer it + // is and the grid renders 0 and 1. + assert!(matches!( + parse_input_for_column("0", Some(&col("tinyint(1)", false)), EditContext::UpdateRow).unwrap(), + Value::Int(0) + )); +} + +#[test] +fn parses_uuid_json_date_time_datetime_timestamptz() { + let uuid = parse_input_for_column( + "550e8400-e29b-41d4-a716-446655440000", + Some(&col("uuid", false)), + EditContext::UpdateRow, + ) + .unwrap(); + assert!(matches!(uuid, Value::Uuid(_))); + + let json = parse_input_for_column(r#"{"a":1}"#, Some(&col("jsonb", false)), EditContext::UpdateRow).unwrap(); + assert!(matches!(json, Value::Json(_))); + + let date = parse_input_for_column("2024-01-15", Some(&col("date", false)), EditContext::UpdateRow).unwrap(); + assert!(matches!(date, Value::Date(_))); + + let time = parse_input_for_column("14:30:00", Some(&col("time", false)), EditContext::UpdateRow).unwrap(); + assert!(matches!(time, Value::Time(_))); + let time_short = parse_input_for_column("14:30", Some(&col("time", false)), EditContext::UpdateRow).unwrap(); + assert!(matches!(time_short, Value::Time(_))); + + let datetime = parse_input_for_column( + "2024-01-15 14:30:00", + Some(&col("timestamp", false)), + EditContext::UpdateRow, + ) + .unwrap(); + assert!(matches!(datetime, Value::Timestamp(_))); + let datetime_t = parse_input_for_column( + "2024-01-15T14:30:00", + Some(&col("datetime", false)), + EditContext::UpdateRow, + ) + .unwrap(); + assert!(matches!(datetime_t, Value::Timestamp(_))); +} + +#[test] +fn rejects_invalid_type_specific_input() { + assert!(parse_input_for_column("not-a-number", Some(&col("integer", false)), EditContext::UpdateRow).is_err()); + assert!(parse_input_for_column("not-a-uuid", Some(&col("uuid", false)), EditContext::UpdateRow).is_err()); + assert!(parse_input_for_column("{not json", Some(&col("jsonb", false)), EditContext::UpdateRow).is_err()); + assert!(parse_input_for_column("2024/01/15", Some(&col("date", false)), EditContext::UpdateRow).is_err()); + assert!(parse_input_for_column("13:00:99", Some(&col("time", false)), EditContext::UpdateRow).is_err()); + assert!( + parse_input_for_column("128", Some(&col("tinyint", false)), EditContext::UpdateRow).is_err(), + "a number past the column's range was accepted" + ); + assert!(parse_input_for_column("not-a-date", Some(&col("timestamp", false)), EditContext::UpdateRow).is_err()); + assert!(parse_input_for_column("maybe", Some(&col("boolean", false)), EditContext::UpdateRow).is_err()); +} + +#[test] +fn unknown_type_falls_through_to_text() { + let r = parse_input_for_column( + "anything goes here", + Some(&col("varchar(255)", false)), + EditContext::UpdateRow, + ) + .unwrap(); + assert!(matches!(r, Value::Text(_))); +} + +#[test] +fn null_sentinel_typed_literally_is_text() { + // Only an actually-empty input becomes NULL. The grid clears the + // visual sentinel before an edit, so `` here was typed on + // purpose. + let r = parse_input_for_column("", Some(&col("text", true)), EditContext::UpdateRow).unwrap(); + match r { + Value::Text(s) => assert_eq!(s, ""), + other => panic!("expected Text(\"\") got {other:?}"), + } +} + +#[test] +fn build_pk_single_column() { + let cells = vec![Value::Int(42), Value::Text("alice".into())]; + let (key, returned) = build_persisted_row_key(cells.clone(), &[0]).expect("valid PK"); + assert!(matches!(key, RowKey::Persisted(_))); + assert_eq!(returned, cells); +} + +#[test] +fn build_pk_multi_column_preserves_index_order() { + // Two tables with the same columns in a different key order must not + // produce colliding keys. + let cells = vec![ + Value::Text("eu-west".into()), + Value::Text("ignored".into()), + Value::Int(7), + ]; + let (key, _) = build_persisted_row_key(cells, &[2, 0]).expect("composite PK"); + let RowKey::Persisted(kv) = key else { + panic!("expected Persisted") + }; + assert_eq!(kv.len(), 2); +} + +#[test] +fn build_pk_empty_indices_returns_none() { + let cells = vec![Value::Int(1)]; + assert!(build_persisted_row_key(cells, &[]).is_none()); +} + +#[test] +fn build_pk_index_out_of_range_returns_none() { + let cells = vec![Value::Int(1), Value::Text("x".into())]; + assert!(build_persisted_row_key(cells, &[5]).is_none()); +} + +#[test] +fn build_pk_partial_out_of_range_returns_none() { + let cells = vec![Value::Int(1)]; + assert!(build_persisted_row_key(cells, &[0, 1]).is_none()); +} + +#[test] +fn build_pk_with_null_components_is_allowed() { + let cells = vec![Value::Null, Value::Text("x".into())]; + let (key, _) = build_persisted_row_key(cells, &[0]).expect("Null PK is valid"); + assert!(matches!(key, RowKey::Persisted(_))); +} + +fn row_at(store: >k4::gio::ListStore, position: u32) -> RowObject { + store.item(position).and_downcast::().expect("a row") +} + +#[test] +fn visual_changes_add_update_and_remove_draft_rows() { + let store = gtk4::gio::ListStore::new::(); + store.append(&RowObject::new(vec![Value::Int(1)])); + + apply_visual_change( + &store, + &VisualChange::InsertDraft { + draft_id: 4, + values: vec![Value::Null], + }, + ); + apply_visual_change( + &store, + &VisualChange::SetDraftCell { + draft_id: 4, + col: 0, + value: Value::Int(9), + }, + ); + assert_eq!(row_at(&store, 0).draft_id(), Some(4)); + assert_eq!(row_at(&store, 0).cell_value(0), Value::Int(9)); + + apply_visual_change(&store, &VisualChange::RemoveDraft { draft_id: 4 }); + assert_eq!(store.n_items(), 1); + assert_eq!(row_at(&store, 0).draft_id(), None); +} + +fn column(name: &str, primary_key: bool) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } +} + +fn rows(names: &[&str]) -> QueryResult { + let columns = [column("id", true), column("name", false)]; + QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + names + .iter() + .enumerate() + .map(|(id, name)| vec![Value::Int(i64::try_from(id).unwrap()), Value::Text((*name).to_owned())]) + .collect(), + ) +} + +/// A connection that serves the table's catalog and count, with the +/// page left to the test. +fn serving_table() -> ConnectionScript { + let mut script = ConnectionScript::default(); + script + .fetch_columns + .always(FakeReply::ok(vec![column("id", true), column("name", false)])); + script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + })); + script.count_rows.always(FakeReply::ok(2)); + script.probe.always(FakeReply::ok(())); + script +} + +struct Harness { + tab: Option, + session: Arc, + connection: Arc, + fake: FakeSession, + _root: tempfile::TempDir, + runtime: Option, +} + +impl Harness { + fn open(script: ConnectionScript, reconnects: impl IntoIterator) -> Self { + libadwaita::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let tasks = runtime.tasks(); + let fake = FakeSession::start(script, reconnects, tasks.clone()); + let root = tempfile::tempdir().expect("tempdir"); + let tab = BrowseTab::new(BrowseTabInit { + tracker: crate::services::tab_tracker_handle::TabTrackerHandle::default(), + schema: None, + table: "users".to_owned(), + connection_id: Some(uuid::Uuid::new_v4()), + read_only: false, + page_size: 100, + initial_offset: 0, + initial_sort: None, + column_widths: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), &tasks), + filter_settings: crate::persistence::FilterSettingsStore::load(root.path().join("filters.json"), &tasks), + session: fake.session.clone(), + tasks, + }); + Self { + tab: Some(tab), + session: fake.session.clone(), + connection: fake.connection.clone(), + fake, + _root: root, + runtime: Some(runtime), + } + } + + fn tab(&self) -> &BrowseTab { + self.tab.as_ref().expect("the tab is open") + } + + fn shown(&self) -> Vec { + self.tab() + .snapshot() + .map(|result| result.rows.into_iter().filter_map(|row| row.get(1).cloned()).collect()) + .unwrap_or_default() + } + + fn visible_page(&self) -> Option { + self.tab().imp_stack().visible_child_name().map(|name| name.to_string()) + } +} + +impl Drop for Harness { + fn drop(&mut self) { + self.tab.take(); + drain_main_context(); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} + +impl BrowseTab { + fn imp_stack(&self) -> gtk4::Stack { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + self.imp().inner_stack.get() + } +} + +fn text(values: &[&str]) -> Vec { + values.iter().map(|value| Value::Text((*value).to_owned())).collect() +} + +fn not_null_name() -> FilterSet { + FilterSet { + rules: vec![FilterRule { + column: "name".to_owned(), + op: FilterOp::IsNotNull, + value: None, + }], + ..FilterSet::default() + } +} + +#[gtk4::test] +fn stale_page_reply_ignored() { + let mut script = serving_table(); + script + .query_bound + .when_sql_contains("IS NOT NULL", FakeReply::ok(rows(&["filtered"]))) + .once(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(rows(&["unfiltered", "stale"])), + }); + let harness = Harness::open(script, []); + let counters = harness.connection.counters(); + wait_until(WAIT, || CallCounters::get(&counters.query_bound) == 1).expect("the first page was asked for"); + + harness.tab().send(BrowseTabInput::FilterApplied(not_null_name())); + wait_until(WAIT, || harness.shown() == text(&["filtered"])).expect("the filtered page"); + std::thread::sleep(Duration::from_millis(500)); + drain_main_context(); + + assert_eq!(CallCounters::get(&counters.query_bound), 2); + assert_eq!( + harness.shown(), + text(&["filtered"]), + "the reply to the page the filter replaced was shown" + ); +} + +#[gtk4::test] +fn closing_tab_cancels_call_options_token() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::UntilStopped); + let mut harness = Harness::open(script, []); + let counters = harness.connection.counters(); + wait_until(WAIT, || CallCounters::get(&counters.query_bound) == 1).expect("the page was asked for"); + let weak = harness.tab().downgrade(); + + harness.tab.take(); + drain_main_context(); + + assert!(weak.upgrade().is_none(), "something kept the closed tab alive"); + wait_until(WAIT, || CallCounters::get(&counters.stopped) == 1).expect("closing the tab did not stop its page read"); +} + +#[gtk4::test] +fn failed_load_refetches_after_link_replace() { + let mut first = serving_table(); + first.query_bound.always(FakeReply::err(DriverError::ConnectionLost { + during: LossPhase::Statement, + })); + let mut second = serving_table(); + second.query_bound.always(FakeReply::ok(rows(&["after reconnect"]))); + let harness = Harness::open(first, [FakeEstablish::Connect(second)]); + + wait_until(WAIT, || harness.shown() == text(&["after reconnect"])) + .expect("the page read again after the reconnect"); + + let replacement = harness.fake.replacement(); + assert_eq!(harness.session.link_generation().get(), 1); + assert_eq!(CallCounters::get(&replacement.counters().query_bound), 1); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); +} + +#[gtk4::test] +fn loaded_tab_keeps_data_on_link_replace() { + let mut first = serving_table(); + first.query_bound.always(FakeReply::ok(rows(&["kept"]))); + let harness = Harness::open(first, [FakeEstablish::Connect(serving_table())]); + wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); + let counters = harness.connection.counters(); + + harness.connection.mark_lost(); + wait_until(WAIT, || harness.session.link_generation().get() == 1).expect("the reconnect"); + std::thread::sleep(Duration::from_millis(200)); + drain_main_context(); + + let replacement = harness.fake.replacement(); + assert_eq!(CallCounters::get(&counters.query_bound), 1); + assert_eq!( + CallCounters::get(&replacement.counters().query_bound), + 0, + "a tab with nothing failed read its page again" + ); + assert_eq!(harness.shown(), text(&["kept"])); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); +} + +#[gtk4::test] +fn schema_change_reads_the_columns_again_then_the_page() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["row"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["row"])).expect("the first page"); + let counters = harness.connection.counters(); + + harness + .session + .invalidate_table(&tablepro_core::TableRef::unqualified("users")); + harness.tab().send(BrowseTabInput::SchemaChanged); + wait_until(WAIT, || CallCounters::get(&counters.query_bound) == 2).expect("the page after the columns"); + + assert_eq!(CallCounters::get(&counters.fetch_columns), 2); +} + +#[gtk4::test] +fn browse_tab_and_filter_strip_freed_after_drop() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["row"]))); + let mut harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["row"])).expect("the first page"); + let strip = { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + harness + .tab() + .imp() + .model + .borrow() + .as_ref() + .and_then(|model| model.filter_strip.as_ref().map(|strip| strip.widget.downgrade())) + .expect("the filter strip") + }; + let tab = harness.tab().downgrade(); + + harness.tab.take(); + drain_main_context(); + + assert!(strip.upgrade().is_none(), "the filter strip outlived its tab"); + assert!(tab.upgrade().is_none(), "the browse tab outlived its last reference"); +} diff --git a/linux/data/resources/resources.gresource.xml b/linux/data/resources/resources.gresource.xml index 7d4ea4131a..3ff6df01d0 100644 --- a/linux/data/resources/resources.gresource.xml +++ b/linux/data/resources/resources.gresource.xml @@ -3,6 +3,7 @@ style.css shortcuts-dialog.ui + ui/browse-tab.ui ui/connect-dialog.ui ui/preferences-dialog.ui ui/results-pane.ui diff --git a/linux/data/resources/ui/browse-tab.ui b/linux/data/resources/ui/browse-tab.ui new file mode 100644 index 0000000000..f33a3d1ae7 --- /dev/null +++ b/linux/data/resources/ui/browse-tab.ui @@ -0,0 +1,227 @@ + + + + + + diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 8ba9adadad..d24d5ec37d 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -50,7 +50,6 @@ crates/app/src/test_support/tests.rs crates/app/src/test_support/unlabelled_widget.rs crates/app/src/test_support/wait_timed_out.rs crates/app/src/test_support/widget_lookup.rs -crates/app/src/ui/app/browse.rs crates/app/src/ui/app/connect_flow.rs crates/app/src/ui/app/connection.rs crates/app/src/ui/app/import.rs @@ -61,7 +60,13 @@ crates/app/src/ui/app/status_pages.rs crates/app/src/ui/app/structure.rs crates/app/src/ui/app/teardown_guard.rs crates/app/src/ui/app/workspace_tabs.rs -crates/app/src/ui/browse_tab.rs +crates/app/src/ui/browse_tab/browse_command.rs +crates/app/src/ui/browse_tab/browse_request_kind.rs +crates/app/src/ui/browse_tab/fetch.rs +crates/app/src/ui/browse_tab/imp.rs +crates/app/src/ui/browse_tab/mod.rs +crates/app/src/ui/browse_tab/page_reply.rs +crates/app/src/ui/browse_tab/tests.rs crates/app/src/ui/cell_editor.rs crates/app/src/ui/connect_dialog/connect_form.rs crates/app/src/ui/connect_dialog/connect_submission.rs @@ -143,6 +148,7 @@ data/app.tablepro.TablePro.desktop.in.in data/app.tablepro.TablePro.gschema.xml data/app.tablepro.TablePro.metainfo.xml.in.in data/resources/shortcuts-dialog.ui +data/resources/ui/browse-tab.ui data/resources/ui/connect-dialog.ui data/resources/ui/preferences-dialog.ui data/resources/ui/results-pane.ui From 23f5168dff74ed89a04bfa9ddffff40794a0a505 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 19:29:04 +0700 Subject: [PATCH 089/178] refactor(linux): let structure tabs read their own definition StructureTab is a gtk-rs widget with a composite template now. It reads the table's definition through the session itself, one request at a time, and reads it again once the session reconnects if the last read failed. --- linux/crates/app/src/ui/app/mod.rs | 30 +- linux/crates/app/src/ui/app/structure.rs | 131 +- linux/crates/app/src/ui/app/workspace_tabs.rs | 110 +- .../app/src/ui/structure_tab/columns.rs | 11 +- linux/crates/app/src/ui/structure_tab/fks.rs | 9 +- linux/crates/app/src/ui/structure_tab/imp.rs | 108 ++ .../app/src/ui/structure_tab/indexes.rs | 9 +- linux/crates/app/src/ui/structure_tab/mod.rs | 1427 ++++++++--------- .../src/ui/structure_tab/structure_command.rs | 28 + .../crates/app/src/ui/structure_tab/tests.rs | 242 +++ .../app/src/ui/structure_tab_dialogs.rs | 29 +- linux/data/resources/resources.gresource.xml | 1 + linux/data/resources/ui/structure-tab.ui | 252 +++ linux/po/POTFILES.in | 4 + 14 files changed, 1406 insertions(+), 985 deletions(-) create mode 100644 linux/crates/app/src/ui/structure_tab/imp.rs create mode 100644 linux/crates/app/src/ui/structure_tab/structure_command.rs create mode 100644 linux/crates/app/src/ui/structure_tab/tests.rs create mode 100644 linux/data/resources/ui/structure-tab.ui diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index ddb586b28b..1692842196 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -8,8 +8,6 @@ mod structure; mod teardown_guard; mod workspace_tabs; -use std::sync::Arc; - use relm4::adw::prelude::*; use relm4::factory::FactoryVecDeque; use relm4::gtk::{gio, glib}; @@ -227,7 +225,7 @@ pub struct EditorTabSlot { pub struct StructureTabSlot { pub id: Uuid, - pub controller: Controller, + pub structure: crate::ui::structure_tab::StructureTab, pub page: adw::TabPage, pub schema: Option, /// Empty in `New` mode until SaveCompleted carries the canonical @@ -278,9 +276,9 @@ impl WorkspaceTab { /// The Structure-side controller. Only `Structure` slots carry /// one (Table slots no longer fuse the DDL editor in). - pub fn structure_controller(&self) -> Option<&Controller> { + pub fn structure_tab(&self) -> Option<&crate::ui::structure_tab::StructureTab> { match self { - WorkspaceTab::Structure(s) => Some(&s.controller), + WorkspaceTab::Structure(s) => Some(&s.structure), _ => None, } } @@ -567,21 +565,6 @@ pub enum AppMsg { }, /// Structure tab Save failed; tracker is intact for retry. StructureSaveFailed(Uuid, String), - /// Structure tab Edit-mode init triggers introspection. - FetchStructureData { - tab_id: Uuid, - }, - /// The table as one definition, read in one call, with the dialect - /// of the connection that read it, which the tab spells its DDL in. - StructureDataLoaded { - tab_id: Uuid, - definition: Box, - dialect: Arc, - }, - StructureLoadFailed { - tab_id: Uuid, - message: String, - }, /// Tracker for a Structure tab crossed empty / non-empty boundary. /// Mirrors `BrowseTabDirtyChanged` for the title prefix and the /// AdwTabPage::set_needs_attention background-tab indicator. @@ -1491,13 +1474,6 @@ impl SimpleComponent for App { self.on_structure_save_completed(tab_id, new_table_name, sender) } AppMsg::StructureSaveFailed(tab_id, message) => self.on_structure_save_failed(tab_id, message), - AppMsg::FetchStructureData { tab_id } => self.on_fetch_structure_data(tab_id, sender), - AppMsg::StructureDataLoaded { - tab_id, - definition, - dialect, - } => self.on_structure_data_loaded(tab_id, *definition, dialect), - AppMsg::StructureLoadFailed { tab_id, message } => self.on_structure_load_failed(tab_id, message), AppMsg::StructureTabDirtyChanged(tab_id, dirty) => self.refresh_structure_tab_dirty(tab_id, dirty), AppMsg::SchemaChanged { schema, table } => self.on_schema_changed(schema, table), AppMsg::ReloadConnections => self.on_reload_connections(sender), diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index 2a23572dfa..c054034975 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use relm4::adw::prelude::*; -use relm4::{ComponentController, ComponentSender, adw}; +use relm4::{ComponentSender, adw}; use uuid::Uuid; use tablepro_core::ddl::TableDefinition; @@ -35,18 +35,6 @@ impl App { return; } self.append_new_structure_tab(schema, sender); - // A draft has no table to read the dialect back with, so the - // session's current one is handed over for its SQL preview. - let dialect = self.context.active.get().and_then(|session| session.current_dialect()); - if let (Some(tab_id), Some(dialect)) = (self.selected_workspace_tab_id(), dialect) - && let Some(controller) = self - .workspace_tabs - .borrow() - .get(&tab_id) - .and_then(|tab| tab.structure_controller()) - { - let _ = controller.sender().send(StructureTabInput::DialectReady(dialect)); - } } /// Sidebar right-click → "Edit Structure". Opens a dedicated @@ -385,16 +373,7 @@ impl App { }); } SaveKind::UpdateInPlace(schema, table) => { - if let Some(controller) = self - .workspace_tabs - .borrow() - .get(&tab_id) - .and_then(|t| t.structure_controller()) - { - let _ = controller - .sender() - .send(StructureTabInput::SaveCompleted { new_table_name }); - } + self.send_to_structure_tab(tab_id, StructureTabInput::SaveCompleted { new_table_name }); sender.input(AppMsg::SchemaChanged { schema, table: Some(table), @@ -431,14 +410,7 @@ impl App { // tab will spuriously close the window. self.close_after_save.borrow_mut().remove(&tab_id); self.close_window_after_save.set(false); - if let Some(controller) = self - .workspace_tabs - .borrow() - .get(&tab_id) - .and_then(|t| t.structure_controller()) - { - let _ = controller.sender().send(StructureTabInput::SaveFailed(message)); - } + self.send_to_structure_tab(tab_id, StructureTabInput::SaveFailed(message)); } /// Sidebar "Show CREATE TABLE": synthesise the canonical CREATE @@ -473,96 +445,48 @@ impl App { }); } - /// Edit-mode init asks for introspection. The definition comes back - /// in one read, so the tab rebuilds its UI once with columns, - /// indexes and foreign keys from the same moment. - pub(super) fn on_fetch_structure_data(&self, tab_id: Uuid, sender: ComponentSender) { - let table = { - let tabs = self.workspace_tabs.borrow(); - let Some(slot) = tabs.get(&tab_id) else { - return; - }; - let Some((schema, table)) = slot.schema_table() else { - return; - }; - TableRef::new(schema.map(str::to_owned), table) - }; - let Some(session) = self.context.active.get() else { - sender.input(AppMsg::StructureLoadFailed { - tab_id, - message: crate::i18n::gettext("No active connection."), - }); - return; - }; - crate::runtime::spawn_and_deliver(&self.context.tasks, read_definition(session, table), move |result| { - match settled(result) { - Ok((definition, dialect)) => sender.input(AppMsg::StructureDataLoaded { - tab_id, - definition: Box::new(definition), - dialect, - }), - Err(error) => sender.input(AppMsg::StructureLoadFailed { - tab_id, - message: error_text::operation_error_message(&error), - }), - } - }); - } - - pub(super) fn on_structure_data_loaded( - &self, - tab_id: Uuid, - definition: TableDefinition, - dialect: Arc, - ) { - if let Some(controller) = self + fn send_to_structure_tab(&self, tab_id: Uuid, msg: StructureTabInput) { + let structure = self .workspace_tabs .borrow() .get(&tab_id) - .and_then(|t| t.structure_controller()) - { - let _ = controller.sender().send(StructureTabInput::StructureLoaded { - definition: Box::new(definition), - dialect, - }); - } - } - - pub(super) fn on_structure_load_failed(&self, tab_id: Uuid, message: String) { - if let Some(controller) = self - .workspace_tabs - .borrow() - .get(&tab_id) - .and_then(|t| t.structure_controller()) - { - let _ = controller.sender().send(StructureTabInput::LoadFailed(message)); + .and_then(|tab| tab.structure_tab()) + .cloned(); + if let Some(structure) = structure { + structure.send(msg); } } /// The schema changed under the window. The catalog forgets the - /// table's columns and reads the sidebar's list again, and every - /// Browse tab open on the table reads it again through its own - /// requests, which share the catalog's one column fetch. + /// table's columns and reads the sidebar's list again, and every tab + /// open on the table reads it again through its own requests, which + /// share the catalog's one column fetch. pub(super) fn on_schema_changed(&self, schema: Option, table: Option) { let Some(session) = self.context.active.get() else { return; }; if let Some(table) = table { session.invalidate_table(&TableRef::new(schema.clone(), table.clone())); - let affected: Vec = self + let (browse_tabs, structure_tabs): (Vec<_>, Vec<_>) = self .workspace_tabs .borrow() .values() .filter_map(|tab| match tab { WorkspaceTab::Table(slot) if slot.schema == schema && slot.table == table => { - Some(slot.browse.clone()) + Some((Some(slot.browse.clone()), None)) + } + WorkspaceTab::Structure(slot) if slot.schema == schema && slot.table == table => { + Some((None, Some(slot.structure.clone()))) } _ => None, }) - .collect(); - for browse in affected { + .unzip(); + for browse in browse_tabs.into_iter().flatten() { browse.send(crate::ui::browse_tab::BrowseTabInput::SchemaChanged); } + for structure in structure_tabs.into_iter().flatten() { + structure.send(StructureTabInput::SchemaChanged); + } } // The new list reaches the sidebar through the catalog watch. self.context.tasks.spawn_task(async move { @@ -698,13 +622,10 @@ fn create_table_sql( /// panic as the bug it is, an abort as the app stopping the work. fn settled(result: Result, TaskFailure>) -> Result { result.unwrap_or_else(|failure| { - Err(match failure { - TaskFailure::Panicked(panic) => { - tracing::error!(%panic, "a structure task panicked"); - OperationError::Panicked(panic) - } - TaskFailure::Aborted => OperationError::Cancelled, - }) + if failure.is_panic() { + tracing::error!(%failure, "a structure task panicked"); + } + Err(failure.into()) }) } diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index a7afaa9475..8d3d8407e3 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -4,13 +4,14 @@ use std::rc::Rc; use relm4::adw::prelude::*; use relm4::gtk::{gio, glib}; -use relm4::{Component, ComponentController, ComponentSender, adw, gtk}; +use relm4::{ComponentSender, adw, gtk}; use uuid::Uuid; use crate::services::workspace_state::{self, ConnectionWorkspaceState, WorkspaceTabRecord}; use crate::ui::browse_tab::{BrowseTab, BrowseTabInit, BrowseTabInput, ExportRequest, SaveRequest}; use crate::ui::editor::{SqlEditor, SqlEditorInit, derive_tab_label}; +use crate::ui::structure_tab::{StructureMode, StructureTab, StructureTabInit}; use super::{ App, AppMsg, CLOSED_TABS_CAPACITY, ClosedTabDescriptor, EditorTabSlot, OpenMode, WorkspaceTab, @@ -182,48 +183,31 @@ impl App { }; let tab_id = Uuid::new_v4(); let driver_id = self.driver_id().to_string(); - let init = crate::ui::structure_tab::StructureTabInit { + let Some(session) = self.context.active.get() else { + return; + }; + let structure = StructureTab::new(StructureTabInit { tracker: self.structure_trackers.register(tab_id), schema: schema.clone(), table: String::new(), - mode: crate::ui::structure_tab::StructureMode::New, + mode: StructureMode::New, driver_id, - // New mode has nothing to introspect (no real table yet). - defer_initial_fetch: false, - }; - let controller = - crate::ui::structure_tab::StructureTab::builder() - .launch(init) - .forward(sender.input_sender(), move |out| match out { - crate::ui::structure_tab::StructureTabOutput::DirtyChanged(dirty) => { - AppMsg::StructureTabDirtyChanged(tab_id, dirty) - } - crate::ui::structure_tab::StructureTabOutput::FetchStructure => { - AppMsg::FetchStructureData { tab_id } - } - crate::ui::structure_tab::StructureTabOutput::ExecuteTransaction { statements } => { - AppMsg::ExecuteStructureTransaction { tab_id, statements } - } - crate::ui::structure_tab::StructureTabOutput::DropTableRequested { schema, table } => { - AppMsg::DropTablePrompt { schema, table } - } - crate::ui::structure_tab::StructureTabOutput::ShowToast(msg) => AppMsg::ShowToast(msg), - crate::ui::structure_tab::StructureTabOutput::ShowAlert { title, body } => { - AppMsg::ShowAlert { title, body } - } - }); + session, + tasks: self.context.tasks.clone(), + }); + connect_structure(&structure, tab_id, sender.input_sender()); - let page = tab_view.append(controller.widget()); + let page = tab_view.append(&structure); page.set_title(&crate::i18n::gettext("New Table")); write_workspace_tab_id(&page, tab_id); let slot = super::StructureTabSlot { id: tab_id, - controller, + structure, page: page.clone(), schema, table: String::new(), - mode: crate::ui::structure_tab::StructureMode::New, + mode: StructureMode::New, }; self.workspace_tabs .borrow_mut() @@ -320,38 +304,22 @@ impl App { }; let tab_id = Uuid::new_v4(); let driver_id = self.driver_id().to_string(); - let mode = crate::ui::structure_tab::StructureMode::Edit; - let init = crate::ui::structure_tab::StructureTabInit { + let Some(session) = self.context.active.get() else { + return; + }; + let mode = StructureMode::Edit; + let structure = StructureTab::new(StructureTabInit { tracker: self.structure_trackers.register(tab_id), schema: schema.clone(), table: table.clone(), mode, driver_id, - defer_initial_fetch: false, - }; - let controller = - crate::ui::structure_tab::StructureTab::builder() - .launch(init) - .forward(sender.input_sender(), move |out| match out { - crate::ui::structure_tab::StructureTabOutput::DirtyChanged(dirty) => { - AppMsg::StructureTabDirtyChanged(tab_id, dirty) - } - crate::ui::structure_tab::StructureTabOutput::FetchStructure => { - AppMsg::FetchStructureData { tab_id } - } - crate::ui::structure_tab::StructureTabOutput::ExecuteTransaction { statements } => { - AppMsg::ExecuteStructureTransaction { tab_id, statements } - } - crate::ui::structure_tab::StructureTabOutput::DropTableRequested { schema, table } => { - AppMsg::DropTablePrompt { schema, table } - } - crate::ui::structure_tab::StructureTabOutput::ShowToast(msg) => AppMsg::ShowToast(msg), - crate::ui::structure_tab::StructureTabOutput::ShowAlert { title, body } => { - AppMsg::ShowAlert { title, body } - } - }); + session, + tasks: self.context.tasks.clone(), + }); + connect_structure(&structure, tab_id, sender.input_sender()); - let page = tab_view.append(controller.widget()); + let page = tab_view.append(&structure); let title = qualified_browse_tab_label(self.sidebar_schemas_distinct(), schema.as_deref(), &table); // Disambiguate from the Data-side tab with the same base name // by suffixing the structure tab title — "products · Structure". @@ -362,7 +330,7 @@ impl App { let slot = super::StructureTabSlot { id: tab_id, - controller, + structure, page: page.clone(), schema, table, @@ -616,6 +584,7 @@ impl App { .discard(&tablepro_storage::DraftScope::default(), slot.draft); } WorkspaceTab::Structure(slot) => { + slot.structure.shutdown(); self.structure_trackers.unregister(slot.id); self.structure_saves_in_flight.borrow_mut().remove(&slot.id); } @@ -1046,7 +1015,10 @@ impl App { // they cannot be saved after it. for tab in self.workspace_tabs.borrow().values() { match tab { - WorkspaceTab::Structure(slot) => self.structure_trackers.unregister(slot.id), + WorkspaceTab::Structure(slot) => { + slot.structure.shutdown(); + self.structure_trackers.unregister(slot.id); + } WorkspaceTab::Table(slot) => { slot.browse.shutdown(); self.change_trackers.unregister(slot.id); @@ -1360,8 +1332,8 @@ fn describe_closed_tab(slot: &WorkspaceTab) -> Option { WorkspaceTab::Structure(s) => match s.mode { // New-Table drafts have no entity to point at and lose // their in-progress DDL on close — nothing to reopen. - crate::ui::structure_tab::StructureMode::New => None, - crate::ui::structure_tab::StructureMode::Edit => Some(ClosedTabDescriptor::Structure { + StructureMode::New => None, + StructureMode::Edit => Some(ClosedTabDescriptor::Structure { schema: s.schema.clone(), table: s.table.clone(), }), @@ -1448,6 +1420,24 @@ fn editor_tab_tooltip(query: &str, label: &str) -> Option { /// Forward an editor tab's signals into the window. The toast overlay /// is the window's, so a replaced query's Undo button lives there. +/// The structure tab's signals, as this window's inputs for the tab. +fn connect_structure(structure: &StructureTab, tab_id: Uuid, input: &relm4::Sender) { + let dirty = input.clone(); + structure.connect_dirty_changed(move |_, is_dirty| dirty.emit(AppMsg::StructureTabDirtyChanged(tab_id, is_dirty))); + let execute = input.clone(); + structure.connect_execute(move |_, statements| { + execute.emit(AppMsg::ExecuteStructureTransaction { tab_id, statements }); + }); + let drop_table = input.clone(); + structure.connect_drop_table_requested(move |_, schema, table| { + drop_table.emit(AppMsg::DropTablePrompt { schema, table }); + }); + let toast = input.clone(); + structure.connect_toast(move |_, text| toast.emit(AppMsg::ShowToast(text))); + let alert = input.clone(); + structure.connect_alert(move |_, title, body| alert.emit(AppMsg::ShowAlert { title, body })); +} + /// The browse tab's signals, as this window's inputs for the tab. fn connect_browse(browse: &BrowseTab, tab_id: Uuid, input: &relm4::Sender) { let state = input.clone(); diff --git a/linux/crates/app/src/ui/structure_tab/columns.rs b/linux/crates/app/src/ui/structure_tab/columns.rs index c07286b8ed..d04298085c 100644 --- a/linux/crates/app/src/ui/structure_tab/columns.rs +++ b/linux/crates/app/src/ui/structure_tab/columns.rs @@ -4,13 +4,14 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; -use relm4::adw::prelude::*; -use relm4::gtk::gio; -use relm4::{ComponentSender, adw, gtk}; +use gtk4 as gtk; +use gtk4::gio; +use libadwaita as adw; +use libadwaita::prelude::*; use tablepro_core::sql_ddl::DraftColumn; -use super::{ColumnField, StructureTab, StructureTabInput}; +use super::{ColumnField, StructureTabInput, StructureTabSender}; /// Curated type lists per driver. Free-text input still allowed via /// the combo box's editable entry; this list seeds the dropdown so @@ -158,7 +159,7 @@ pub(super) fn build_column_expander_row( index: usize, col: &DraftColumn, driver_id: &str, - sender: ComponentSender, + sender: StructureTabSender, suppress_emit: Rc>, popover_registry: Rc>>, ) -> adw::ExpanderRow { diff --git a/linux/crates/app/src/ui/structure_tab/fks.rs b/linux/crates/app/src/ui/structure_tab/fks.rs index 29d1ac67df..05be5e595b 100644 --- a/linux/crates/app/src/ui/structure_tab/fks.rs +++ b/linux/crates/app/src/ui/structure_tab/fks.rs @@ -1,13 +1,14 @@ //! Foreign-key row builder used by the Foreign Keys page of the //! Structure tab. -use relm4::adw::prelude::*; -use relm4::{ComponentSender, adw, gtk}; +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; use tablepro_core::ForeignKeyInfo; use tablepro_core::ddl::ReferentialAction; -use super::{StructureTab, StructureTabInput}; +use super::{StructureTabInput, StructureTabSender}; fn driver_can_drop_foreign_key(driver_id: &str) -> bool { !matches!(driver_id, "sqlite") @@ -20,7 +21,7 @@ pub(super) fn build_fk_row( index: usize, fk: &ForeignKeyInfo, driver_id: &str, - sender: ComponentSender, + sender: StructureTabSender, ) -> adw::ActionRow { let qualified_ref = match &fk.ref_schema { Some(s) if !s.is_empty() => format!("{s}.{}", fk.ref_table), diff --git a/linux/crates/app/src/ui/structure_tab/imp.rs b/linux/crates/app/src/ui/structure_tab/imp.rs new file mode 100644 index 0000000000..93467e1086 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/imp.rs @@ -0,0 +1,108 @@ +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::rc::Rc; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use tablepro_session::runtime::WatchForwarder; + +use super::{StructureModel, StructureTabInput}; + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/structure-tab.ui")] +pub struct StructureTab { + #[template_child] + pub inner_stack: TemplateChild, + #[template_child] + pub name_row: TemplateChild, + #[template_child] + pub name_entry: TemplateChild, + #[template_child] + pub columns_box: TemplateChild, + #[template_child] + pub indexes_box: TemplateChild, + #[template_child] + pub fks_box: TemplateChild, + #[template_child] + pub copy_sql_button: TemplateChild, + #[template_child] + pub sql_view: TemplateChild, + #[template_child] + pub error_status: TemplateChild, + #[template_child] + pub retry_button: TemplateChild, + #[template_child] + pub pending_label: TemplateChild, + #[template_child] + pub drop_button: TemplateChild, + #[template_child] + pub discard_button: TemplateChild, + #[template_child] + pub save_button: TemplateChild, + + pub(super) model: RefCell>, + pub(super) queue: RefCell>, + pub(super) dispatching: Cell, + pub(super) suppress_emit: Rc>, + /// Delivers each reconnect, for as long as the tab is open. + pub(super) link_watch: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for StructureTab { + const NAME: &'static str = "TableProStructureTab"; + type Type = super::StructureTab; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + sourceview5::View::ensure_type(); + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for StructureTab { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("dirty-changed") + .param_types([bool::static_type()]) + .build(), + Signal::builder("execute") + .param_types([glib::BoxedAnyObject::static_type()]) + .build(), + Signal::builder("drop-table-requested") + .param_types([String::static_type(), String::static_type()]) + .build(), + Signal::builder("toast").param_types([String::static_type()]).build(), + Signal::builder("alert") + .param_types([String::static_type(), String::static_type()]) + .build(), + ] + }) + } + + /// Closing the tab stops its read and lets go of the theme handler + /// on the app-wide style manager. + fn dispose(&self) { + self.link_watch.take(); + if let Ok(mut model) = self.model.try_borrow_mut() + && let Some(mut model) = model.take() + { + model.sequencer.cancel_all(); + } + } +} + +impl WidgetImpl for StructureTab {} +impl BinImpl for StructureTab {} diff --git a/linux/crates/app/src/ui/structure_tab/indexes.rs b/linux/crates/app/src/ui/structure_tab/indexes.rs index 804bba1848..e6b7c47252 100644 --- a/linux/crates/app/src/ui/structure_tab/indexes.rs +++ b/linux/crates/app/src/ui/structure_tab/indexes.rs @@ -1,13 +1,14 @@ //! Index-row builder + tag widgets used by the Indexes page of the //! Structure tab. -use relm4::adw::prelude::*; -use relm4::{ComponentSender, adw, gtk}; +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; use tablepro_core::IndexInfo; use tablepro_core::meta::IndexKey; -use super::{StructureTab, StructureTabInput}; +use super::{StructureTabInput, StructureTabSender}; /// Tiny inline pill rendered as an AdwActionRow suffix — used by the /// indexes list to show UNIQUE / PRIMARY tags. `accent_class` is the @@ -23,7 +24,7 @@ fn index_badge(label: &str, accent_class: &str) -> gtk::Label { /// subtitle is the comma-separated column list. UNIQUE / PRIMARY are /// small caption suffixes, the trash button is an end-suffix. The row /// participates in `boxed-list` styling for free; no manual margins. -pub(super) fn build_index_row(index: usize, idx: &IndexInfo, sender: ComponentSender) -> adw::ActionRow { +pub(super) fn build_index_row(index: usize, idx: &IndexInfo, sender: StructureTabSender) -> adw::ActionRow { let subtitle = index_key_summary(idx); let row = adw::ActionRow::builder() .title(glib::markup_escape_text(&idx.name)) diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index 86bc7e00f6..344cfb5a89 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -1,8 +1,7 @@ -//! Structure workspace tab — full schema-management UI for CREATE / -//! DROP / ALTER TABLE, indexes, and foreign keys. +//! The Structure workspace tab: CREATE, DROP and ALTER TABLE, indexes +//! and foreign keys. //! -//! Layout (adw::ToolbarView, no internal HeaderBar — the wrapper's -//! Data/Structure HeaderBar already provides one toolbar strip): +//! Layout (adw::ToolbarView, no internal HeaderBar): //! //! ┌─ Content ────────────────────────────────────────────────┐ //! │ (New mode) AdwPreferencesGroup { name entry } │ @@ -26,35 +25,55 @@ //! ops in a passive per-tab `StructureChangeTracker` so out-of-band //! callers (close-with-pending dialog, save dispatcher) can read the //! current dirty state without touching the UI. There is no per-op -//! undo / redo — the snapshot is the only restore point, exposed via +//! undo or redo: the snapshot is the only restore point, exposed via //! the Discard button. Save calls `materialize_ops` against the same -//! diff and dispatches `ExecuteTransaction` to App. +//! diff and asks the window to run the statements. +//! +//! The tab reads the table's definition itself, one request at a time, +//! and reads it again once the session reconnects if the last read +//! failed. + +mod columns; +mod fks; +mod imp; +mod indexes; +mod structure_command; +#[cfg(test)] +mod tests; use std::cell::{Cell, RefCell}; +use std::collections::{BTreeMap, VecDeque}; use std::rc::Rc; use std::sync::Arc; -use relm4::adw::prelude::*; -use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk}; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; use sourceview5::prelude::BufferExt; - use tablepro_core::ddl::{ColumnDefinition, TableDefinition}; use tablepro_core::dialect::SqlDialect; -use tablepro_core::sql_ddl::{BuildDdlError, DraftColumn}; +use tablepro_core::meta::TableRef; +use tablepro_core::sql_ddl::{BuildDdlError, DraftColumn, StructureOp, diff_to_ops, materialize_ops}; use tablepro_core::{ForeignKeyInfo, IndexInfo}; - +use tablepro_session::runtime::{Tasks, WatchForwarder}; +use tablepro_session::{LinkGeneration, OperationError, OperationKind, Session}; + +use self::columns::{build_column_expander_row, default_type_for}; +use self::fks::build_fk_row; +use self::indexes::build_index_row; +pub(crate) use self::structure_command::{LoadedStructure, StructureCommand, StructureRequestKind}; +use crate::runtime::{FailedRequests, RequestSequencer, RequestTicket, Stamped}; use crate::services::structure_tracker_handle::StructureTrackerHandle; use crate::ui::handler_guard::HandlerGuard; use crate::ui::structure_tab_dialogs::{present_fk_dialog, present_index_dialog}; -use tablepro_core::sql_ddl::{StructureOp, diff_to_ops, materialize_ops}; - -mod columns; -mod fks; -mod indexes; -use columns::{build_column_expander_row, default_type_for}; -use fks::build_fk_row; -use indexes::build_index_row; +glib::wrapper! { + pub struct StructureTab(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} /// Whether the Structure tab is editing an existing table or /// drafting a brand-new one. @@ -64,24 +83,300 @@ pub enum StructureMode { Edit, } -#[derive(Debug)] pub struct StructureTabInit { pub tracker: StructureTrackerHandle, pub schema: Option, pub table: String, pub mode: StructureMode, pub driver_id: String, - /// When `true`, skip the auto-`FetchStructure` fired at the end of - /// init. Used by `append_table_tab` for Data-mode opens — the - /// Structure pane is alive but invisible, so introspection is - /// deferred until the user actually switches to it. Without this, - /// restoring N Table tabs from disk fires N parallel three-query - /// introspection bursts that delay first paint and saturate the - /// driver pool. - pub defer_initial_fetch: bool, + /// The session the tab reads through. A draft takes its dialect + /// from it for the SQL preview. + pub session: Arc, + pub tasks: Tasks, +} + +/// What the rows and dialogs of a tab use to reach it. It holds the tab +/// weakly, so a row's closure never keeps a closed tab alive. +#[derive(Clone)] +pub(crate) struct StructureTabSender { + tab: glib::WeakRef, +} + +impl StructureTabSender { + pub(crate) fn input(&self, msg: StructureTabInput) { + if let Some(tab) = self.tab.upgrade() { + tab.send(msg); + } + } + + pub(crate) fn output(&self, output: StructureTabOutput) { + if let Some(tab) = self.tab.upgrade() { + tab.emit_output(output); + } + } +} + +impl StructureTab { + pub fn new(init: StructureTabInit) -> Self { + let tab: Self = glib::Object::new(); + let imp = tab.imp(); + + let sql_buffer = match sourceview5::LanguageManager::default().language("sql") { + Some(language) => sourceview5::Buffer::with_language(&language), + None => sourceview5::Buffer::new(None), + }; + imp.sql_view.set_buffer(Some(&sql_buffer)); + apply_sql_scheme(&sql_buffer); + let style_manager = adw::StyleManager::default(); + let buffer_for_theme = sql_buffer.downgrade(); + let theme_handler = HandlerGuard::new( + &style_manager, + style_manager.connect_dark_notify(move |_| { + if let Some(buffer) = buffer_for_theme.upgrade() { + apply_sql_scheme(&buffer); + } + }), + ); + // Saves the user clicking into the preview, selecting all and + // copying every time they want the DDL in another tool. + let buffer_for_copy = sql_buffer.downgrade(); + imp.copy_sql_button.connect_clicked(move |button| { + if let Some(buffer) = buffer_for_copy.upgrade() { + let text = buffer.text(&buffer.start_iter(), &buffer.end_iter(), false); + button.clipboard().set_text(text.as_str()); + } + }); + + imp.name_entry.set_text(&init.table); + imp.name_row.set_visible(matches!(init.mode, StructureMode::New)); + imp.drop_button.set_visible(matches!(init.mode, StructureMode::Edit)); + imp.inner_stack.set_visible_child_name(match init.mode { + StructureMode::New => "editor", + StructureMode::Edit => "loading", + }); + tab.connect_widgets(); + + let link_changes = init.session.link_changes(); + let model = StructureModel { + this: tab.downgrade(), + link_generation: init.session.link_generation(), + dialect: Rc::new(RefCell::new(match init.mode { + // A draft has no table to read the dialect back with. + StructureMode::New => init.session.current_dialect(), + StructureMode::Edit => None, + })), + session: init.session, + tasks: init.tasks, + sequencer: RequestSequencer::new(), + failed: FailedRequests::new(), + issued_on: BTreeMap::new(), + tracker: init.tracker, + schema: init.schema, + original_table_name: Rc::new(RefCell::new(init.table.clone())), + table_name: Rc::new(RefCell::new(init.table)), + mode: Rc::new(RefCell::new(init.mode)), + driver_id: init.driver_id, + columns: Rc::new(RefCell::new(Vec::new())), + indexes: Rc::new(RefCell::new(Vec::new())), + foreign_keys: Rc::new(RefCell::new(Vec::new())), + original_columns: Rc::new(RefCell::new(Vec::new())), + original_indexes: Rc::new(RefCell::new(Vec::new())), + original_fks: Rc::new(RefCell::new(Vec::new())), + inner_stack: imp.inner_stack.get(), + error_status: imp.error_status.get(), + name_entry: imp.name_entry.get(), + name_row: imp.name_row.get(), + columns_box: imp.columns_box.get(), + indexes_box: imp.indexes_box.get(), + fks_box: imp.fks_box.get(), + sql_buffer, + pending_label: imp.pending_label.get(), + save_button: imp.save_button.get(), + discard_button: imp.discard_button.get(), + drop_button: imp.drop_button.get(), + last_dirty: Rc::new(RefCell::new(false)), + suppress_emit: imp.suppress_emit.clone(), + next_column_seq: Rc::new(RefCell::new(1)), + column_popovers: Rc::new(RefCell::new(Vec::new())), + refetching: Rc::new(Cell::new(false)), + _theme_handler: theme_handler, + }; + imp.model.replace(Some(model)); + imp.link_watch.replace(Some(WatchForwarder::new( + link_changes, + { + let tab = tab.downgrade(); + move |generation: LinkGeneration| { + if let Some(tab) = tab.upgrade() { + tab.send(StructureTabInput::LinkChanged(generation)); + } + } + }, + |future| { + glib::spawn_future_local(future); + }, + ))); + tab.send(StructureTabInput::Start); + tab + } + + pub(crate) fn sender(&self) -> StructureTabSender { + StructureTabSender { tab: self.downgrade() } + } + + /// Hand the tab an input. One that arrives while another is being + /// handled, from a widget signal the handler set off, waits for it + /// to finish rather than running inside it. + pub(crate) fn send(&self, msg: StructureTabInput) { + let imp = self.imp(); + imp.queue.borrow_mut().push_back(msg); + if imp.dispatching.replace(true) { + return; + } + loop { + let next = imp.queue.borrow_mut().pop_front(); + let Some(msg) = next else { + break; + }; + let mut effects = StructureEffects::default(); + { + let mut model = imp.model.borrow_mut(); + let Some(model) = model.as_mut() else { + imp.queue.borrow_mut().clear(); + break; + }; + model.update(msg, &mut effects); + } + for output in effects.outputs { + self.emit_output(output); + } + imp.queue.borrow_mut().extend(effects.inputs); + } + imp.dispatching.set(false); + } + + /// Stop the read the tab has running and stop following reconnects. + pub fn shutdown(&self) { + let imp = self.imp(); + imp.link_watch.take(); + if let Ok(mut model) = imp.model.try_borrow_mut() + && let Some(model) = model.as_mut() + { + model.sequencer.cancel_all(); + } + } + + pub fn connect_dirty_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "dirty-changed", + false, + glib::closure_local!(move |tab: &Self, dirty: bool| f(tab, dirty)), + ) + } + + /// Save asked the window to run these statements. + pub fn connect_execute) + 'static>(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "execute", + false, + glib::closure_local!(move |tab: &Self, statements: glib::BoxedAnyObject| { + f(tab, statements.borrow::>().clone()); + }), + ) + } + + pub fn connect_drop_table_requested, String) + 'static>( + &self, + f: F, + ) -> glib::SignalHandlerId { + self.connect_closure( + "drop-table-requested", + false, + glib::closure_local!(move |tab: &Self, schema: Option, table: String| f(tab, schema, table)), + ) + } + + pub fn connect_toast(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "toast", + false, + glib::closure_local!(move |tab: &Self, text: String| f(tab, text)), + ) + } + + pub fn connect_alert(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "alert", + false, + glib::closure_local!(move |tab: &Self, title: String, body: String| f(tab, title, body)), + ) + } + + fn emit_output(&self, output: StructureTabOutput) { + match output { + StructureTabOutput::DirtyChanged(dirty) => self.emit_by_name::<()>("dirty-changed", &[&dirty]), + StructureTabOutput::ExecuteTransaction { statements } => { + self.emit_by_name::<()>("execute", &[&glib::BoxedAnyObject::new(statements)]); + } + StructureTabOutput::DropTableRequested { schema, table } => { + self.emit_by_name::<()>("drop-table-requested", &[&schema, &table]); + } + StructureTabOutput::ShowToast(text) => self.emit_by_name::<()>("toast", &[&text]), + StructureTabOutput::ShowAlert { title, body } => self.emit_by_name::<()>("alert", &[&title, &body]), + } + } + + fn connect_widgets(&self) { + let imp = self.imp(); + let on_click = |button: >k::Button, msg: fn() -> StructureTabInput| { + let sender = self.sender(); + button.connect_clicked(move |_| sender.input(msg())); + }; + on_click(&imp.save_button, || StructureTabInput::Save); + on_click(&imp.discard_button, || StructureTabInput::Discard); + on_click(&imp.drop_button, || StructureTabInput::DropTableRequested); + // Without a retry a transient network blip on a remote database + // would force the user to close the tab and open it again. + on_click(&imp.retry_button, || StructureTabInput::Retry); + let sender = self.sender(); + let suppress = imp.suppress_emit.clone(); + imp.name_entry.connect_changed(move |entry| { + if suppress.get() { + return; + } + sender.input(StructureTabInput::TableNameEdited(entry.text().to_string())); + }); + } +} + +#[derive(Default)] +pub(crate) struct StructureEffects { + outputs: Vec, + inputs: VecDeque, } -pub struct StructureTab { +impl StructureEffects { + fn output(&mut self, output: StructureTabOutput) { + self.outputs.push(output); + } + + fn input(&mut self, input: StructureTabInput) { + self.inputs.push_back(input); + } +} + +pub(crate) struct StructureModel { + this: glib::WeakRef, + session: Arc, + tasks: Tasks, + sequencer: RequestSequencer, + failed: FailedRequests, + /// The connection each running read was issued on. A read that + /// fails after the connection was replaced is asked again at once, + /// since the reconnect it would wait for has already landed. + issued_on: BTreeMap, + /// The newest connection the tab has heard of. + link_generation: LinkGeneration, tracker: StructureTrackerHandle, schema: Option, table_name: Rc>, @@ -90,31 +385,24 @@ pub struct StructureTab { columns: Rc>>, indexes: Rc>>, foreign_keys: Rc>>, - /// Snapshot of the columns / indexes / FKs / table name the - /// driver returned at load time. The "edit" surface is the diff - /// between these and the live `columns` / `indexes` / - /// `foreign_keys` / `table_name` fields — `materialize_ops` - /// produces SQL by walking that diff. Discard simply copies the - /// snapshots back over the live fields. + /// The table as the last read returned it. Save writes the diff + /// between these and the live fields, and Discard copies these back + /// over them. original_table_name: Rc>, original_columns: Rc>>, original_indexes: Rc>>, original_fks: Rc>>, /// The connection's dialect, which spells renames and default drops - /// per server. `None` until the session hands it over, and the SQL - /// cannot be written before then. + /// per server. `None` until the first read, and the SQL cannot be + /// written before then. dialect: Rc>>>, - // Widget refs we touch from `update`. inner_stack: gtk::Stack, - /// `AdwStatusPage` shown when initial structure fetch fails. - /// Holding a reference so `LoadFailed` can set its description - /// to the driver error inline — replaces the previous redundant - /// modal `ShowAlert` that double-surfaced the same text. + /// The status page shown when the read fails, with the driver's + /// error as its description. error_status: adw::StatusPage, name_entry: adw::EntryRow, - /// The PreferencesGroup wrapping `name_entry` — visible only in - /// New mode. Hidden after SaveCompleted promotes the tab to Edit. + /// The group around `name_entry`, visible only in New mode. name_row: adw::PreferencesGroup, columns_box: gtk::Box, indexes_box: gtk::Box, @@ -133,29 +421,19 @@ pub struct StructureTab { /// edit and shift the live model away from the snapshot, surfacing /// phantom pending changes. `Cell` (not `RefCell`) because GTK can /// fire those signals re-entrantly during `clear_box` while - /// another `borrow_mut` is still live on the stack — a - /// `RefCell::borrow` racing it would panic. + /// another `borrow_mut` is still live on the stack. suppress_emit: Rc>, - /// Monotonic counter for the "Add Column" placeholder name. Using - /// `vec.len() + 1` produced duplicate `column_1` after the user - /// removed a column; a forever-incrementing counter avoids the - /// collision (validate_save rejects duplicates anyway, but the - /// confusing UX is worse than the no-op rename the user has to - /// do afterwards). + /// Monotonic counter for the "Add Column" placeholder name, so a + /// removed column does not leave its name to be handed out twice. next_column_seq: Rc>, /// The type suggestion popovers of the column rows. A rebuild pops /// them down before it removes the rows, so no menu stays open over /// a row that is gone. column_popovers: Rc>>, - /// True between `SaveCompleted` and the matching `StructureLoaded` - /// (or `LoadFailed`). During this window the live model has been - /// committed to the database but `original_*` snapshots still hold - /// the pre-save state. Diffing the live model against the stale - /// snapshot would surface phantom pending ops — visible to the user - /// as a spurious "Save changes?" prompt if they close the tab - /// mid-refetch. `recompute_dirty_state` short-circuits while this - /// flag is set; `StructureLoaded` flips it back and triggers a - /// fresh recompute against the up-to-date snapshots. + /// True between `SaveCompleted` and the read that follows. The live + /// model is already committed while the `original_*` snapshots + /// still hold the pre-save state, so diffing them would surface + /// phantom pending ops; `recompute_dirty_state` waits it out. refetching: Rc>, /// The SQL preview follows the desktop theme through the app-wide /// style manager, which would keep the handler past the tab. @@ -163,14 +441,17 @@ pub struct StructureTab { } #[derive(Debug)] -pub enum StructureTabInput { - StructureLoaded { - definition: Box, - dialect: Arc, - }, - /// The dialect for a New-mode draft, which has no table to read. - DialectReady(Arc), - LoadFailed(String), +pub(crate) enum StructureTabInput { + /// The tab is built: read the table, or draw an empty draft. + Start, + /// A reply to the tab's read. + Command(StructureCommand), + /// A reconnect replaced the session's connection. + LinkChanged(LinkGeneration), + /// The table's definition changed elsewhere. + SchemaChanged, + /// The error page's Try Again. + Retry, Save, Discard, DropTableRequested, @@ -178,26 +459,18 @@ pub enum StructureTabInput { new_table_name: Option, }, SaveFailed(String), - /// Re-render the columns / indexes / FKs lists + SQL preview from - /// the current model state. Fired after every UI mutation so the - /// rendered grid matches what materialize() will produce. + /// Rebuild the lists and the preview from the model. Refresh, - /// User edited a column's field; push the matching StructureOp. ColumnEdited { index: usize, field: ColumnField, }, - /// User clicked "Add Column" — append a placeholder draft column, - /// push AddColumn op, focus the new row's name entry. AddColumn, - /// User clicked the trash icon on a column row. RemoveColumn(usize), - /// User clicked "Add Index…" → AlertDialog returned with values. AddIndex(IndexInfo), RemoveIndex(usize), AddForeignKey(ForeignKeyInfo), RemoveForeignKey(usize), - /// User edited the table-name entry. TableNameEdited(String), } @@ -213,22 +486,21 @@ pub enum ColumnField { } #[derive(Debug)] -pub enum StructureTabOutput { +pub(crate) enum StructureTabOutput { DirtyChanged(bool), - FetchStructure, ExecuteTransaction { statements: Vec }, DropTableRequested { schema: Option, table: String }, ShowToast(String), ShowAlert { title: String, body: String }, } -impl StructureTab { - /// Compute the pending-op list from the diff between original - /// snapshot and current model state. The single source of truth - /// for "what will Save emit?". Pure function on the live state. - /// - /// New-mode short-circuits to a single `CreateTable` op (or zero - /// ops when the column list is empty). +impl StructureModel { + fn sender(&self) -> StructureTabSender { + StructureTabSender { tab: self.this.clone() } + } + + /// What Save would write: the diff against the snapshot, or one + /// CREATE TABLE for a draft with columns. fn current_diff_ops(&self) -> Vec { if matches!(*self.mode.borrow(), StructureMode::New) { let columns = self.columns.borrow().clone(); @@ -256,19 +528,13 @@ impl StructureTab { ) } - /// Refresh action-bar state + SQL preview + emit `DirtyChanged` - /// based on the current diff. Called after every model mutation. - /// Also populates the per-tab tracker cache so out-of-band - /// callers (close-with-pending, save-by-id) can read the same op - /// list without re-deriving it from the tab's model. + /// The action bar, the preview and the tracker after an edit. The + /// tracker holds the ops so the window's close and save paths read + /// them without the tab. /// - /// No-op while `refetching` is set: between `SaveCompleted` and - /// `StructureLoaded`, `original_*` is stale, so diffing the live - /// model would produce ops for changes that were already - /// committed. The cache is left at whatever `SaveCompleted` - /// cleared it to (empty); `StructureLoaded` flips the flag and - /// runs a fresh recompute against the up-to-date snapshots. - fn recompute_dirty_state(&self, sender: &ComponentSender) { + /// Waits while `refetching`: between `SaveCompleted` and the read + /// after it, `original_*` is stale. + fn recompute_dirty_state(&self, fx: &mut StructureEffects) { if self.refetching.get() { return; } @@ -282,7 +548,7 @@ impl StructureTab { let mut last = self.last_dirty.borrow_mut(); if *last != dirty { *last = dirty; - let _ = sender.output(StructureTabOutput::DirtyChanged(dirty)); + fx.output(StructureTabOutput::DirtyChanged(dirty)); } } @@ -297,7 +563,7 @@ impl StructureTab { crate::i18n::ngettext_f( "{n} pending change", "{n} pending changes", - pending_count as u32, + u32::try_from(pending_count).unwrap_or(u32::MAX), &[("n", &pending_count.to_string())], ) }; @@ -325,19 +591,10 @@ impl StructureTab { self.sql_buffer.set_text(&text); } - fn rebuild_columns_view(&self, sender: ComponentSender) { - // Tear down + rebuild. Editing happens infrequently enough - // that rebuilding the whole layout per change is cheap. - // - // suppress_emit must be true while we tear down + recreate - // the row widgets: AdwEntryRow::set_text and SwitchRow::set_active - // for the initial values fire `changed` / `notify::active` - // signals synchronously, and the row's connect_* callbacks - // (registered earlier in the build) would treat those as user - // edits — every Edit-mode reload would shift the live model - // away from the snapshot and surface phantom pending changes. - // We re-enable emit on the next idle tick so legitimate user - // input afterwards flows through. + fn rebuild_columns_view(&self) { + // Seeding the new rows' widgets fires their change signals, + // which must not count as user edits; emit comes back on the + // next idle tick. self.suppress_emit.set(true); { let mut popovers = self.column_popovers.borrow_mut(); @@ -348,45 +605,37 @@ impl StructureTab { clear_box(&self.columns_box); let driver_id = self.driver_id.clone(); - // Native column editor: boxed-list `gtk::ListBox` of one - // `adw::ExpanderRow` per column. Each expander's collapsed - // header reads as a row in a Settings-style list (column - // name + summary subtitle); expanding reveals AdwEntryRow / - // AdwSwitchRow children for the editable attributes. Add - // Column appears as the trailing AdwButtonRow inside the - // same boxed-list — the GNOME pattern matching Settings's - // "Add Network" or Builder's run-config list. let list = boxed_list(); for (i, col) in self.columns.borrow().iter().enumerate() { list.append(&build_column_expander_row( i, col, &driver_id, - sender.clone(), + self.sender(), self.suppress_emit.clone(), self.column_popovers.clone(), )); } - let sender_for_add = sender.clone(); + let sender_for_add = self.sender(); append_add_button(&list, &crate::i18n::gettext("Add Column"), move || { sender_for_add.input(StructureTabInput::AddColumn); }); self.columns_box.append(&list); let suppress = self.suppress_emit.clone(); - relm4::gtk::glib::idle_add_local_once(move || { + glib::idle_add_local_once(move || { suppress.set(false); }); } - fn rebuild_indexes_view(&self, sender: ComponentSender) { + fn rebuild_indexes_view(&self) { clear_box(&self.indexes_box); let list = boxed_list(); for (i, idx) in self.indexes.borrow().iter().enumerate() { - list.append(&build_index_row(i, idx, sender.clone())); + list.append(&build_index_row(i, idx, self.sender())); } let columns_for_dialog = self.columns.clone(); - let sender_for_add = sender.clone(); + let sender_for_add = self.sender(); let parent_box = self.indexes_box.downgrade(); append_add_button(&list, &crate::i18n::gettext("Add Index…"), move || { if let Some(parent_box) = parent_box.upgrade() { @@ -400,15 +649,15 @@ impl StructureTab { self.indexes_box.append(&list); } - fn rebuild_fks_view(&self, sender: ComponentSender) { + fn rebuild_fks_view(&self) { clear_box(&self.fks_box); let driver_id = self.driver_id.clone(); let list = boxed_list(); for (i, fk) in self.foreign_keys.borrow().iter().enumerate() { - list.append(&build_fk_row(i, fk, &driver_id, sender.clone())); + list.append(&build_fk_row(i, fk, &driver_id, self.sender())); } let columns_for_dialog = self.columns.clone(); - let sender_for_add = sender.clone(); + let sender_for_add = self.sender(); let parent_box = self.fks_box.downgrade(); let driver_id_for_dialog = driver_id.clone(); append_add_button(&list, &crate::i18n::gettext("Add Foreign Key…"), move || { @@ -423,483 +672,150 @@ impl StructureTab { }); self.fks_box.append(&list); } -} -/// Build a `gtk::ListBox` with the `.boxed-list` HIG style class. Used -/// for the columns / indexes / FKs sections of the Structure tab so -/// rows pick up the standard Adwaita rounded-corner + row-separator -/// treatment used in GNOME Settings, Files, etc. -fn boxed_list() -> gtk::ListBox { - let list = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .margin_start(12) - .margin_end(12) - .margin_top(6) - .margin_bottom(6) - .build(); - list.add_css_class("boxed-list"); - list -} - -fn clear_box(b: >k::Box) { - while let Some(child) = b.first_child() { - b.remove(&child); + fn table_ref(&self) -> TableRef { + TableRef::new(self.schema.clone(), self.table_name.borrow().clone()) } -} -/// Append a trailing `adw::ButtonRow` to a boxed-list — the GNOME -/// pattern for "Add another item" rows in Settings / Builder. The -/// caller's closure runs on activation; what it dispatches (a model -/// mutation for Add Column, a dialog launcher for Add Index / Add -/// Foreign Key) is the only thing that varies between the columns, -/// indexes, and FKs views. -fn append_add_button(list: >k::ListBox, label: &str, on_activate: impl Fn() + 'static) { - let row = adw::ButtonRow::builder() - .title(label) - .start_icon_name(crate::ui::icons::LIST_ADD) - .build(); - row.connect_activated(move |_| on_activate()); - list.append(&row); -} - -/// Validate the model against driver constraints before Save. Returns -/// the first user-visible error string, or None if all checks pass. -fn validate_save(table_name: &str, columns: &[DraftColumn], mode: StructureMode) -> Result<(), String> { - if matches!(mode, StructureMode::New) && table_name.trim().is_empty() { - return Err(crate::i18n::gettext("Table name is required.")); - } - // Empty-columns guard applies in BOTH modes. In Edit mode, the - // user pressing the trash on every row would otherwise produce - // a Save that drops every column — most drivers either reject - // this with an opaque error or silently degenerate the table. - if columns.is_empty() { - return Err(crate::i18n::gettext("At least one column is required.")); - } - let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); - for col in columns { - if col.name.trim().is_empty() { - return Err(crate::i18n::gettext("Every column needs a name.")); - } - if !seen.insert(col.name.as_str()) { - return Err(crate::i18n::gettext_f( - "Duplicate column name: {name}", - &[("name", &col.name)], - )); - } - if col.data_type.trim().is_empty() { - return Err(crate::i18n::gettext_f( - "Column {name} needs a type.", - &[("name", &col.name)], - )); - } - if col.primary_key && col.nullable { - return Err(crate::i18n::gettext_f( - "Primary key columns must be NOT NULL: {name}", - &[("name", &col.name)], - )); - } - } - Ok(()) -} - -impl SimpleComponent for StructureTab { - type Init = StructureTabInit; - type Input = StructureTabInput; - type Output = StructureTabOutput; - type Root = adw::ToolbarView; - type Widgets = (); - - fn init_root() -> Self::Root { - adw::ToolbarView::new() - } - - fn init(init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { - let view_stack = adw::ViewStack::new(); - - let columns_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(0) - .build(); - let columns_scroll = gtk::ScrolledWindow::builder().child(&columns_box).vexpand(true).build(); - let columns_page = view_stack.add_titled_with_icon( - &columns_scroll, - Some("columns"), - &crate::i18n::gettext("Columns"), - crate::ui::icons::VIEW_LIST, - ); - let _ = columns_page; - - let indexes_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(0) - .build(); - let indexes_scroll = gtk::ScrolledWindow::builder().child(&indexes_box).vexpand(true).build(); - let indexes_page = view_stack.add_titled_with_icon( - &indexes_scroll, - Some("indexes"), - &crate::i18n::gettext("Indexes"), - crate::ui::icons::VIEW_SORT_ASCENDING, - ); - let _ = indexes_page; - - let fks_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(0) - .build(); - let fks_scroll = gtk::ScrolledWindow::builder().child(&fks_box).vexpand(true).build(); - let fks_page = view_stack.add_titled_with_icon( - &fks_scroll, - Some("fks"), - &crate::i18n::gettext("Foreign Keys"), - crate::ui::icons::TABLE_RELATION, - ); - let _ = fks_page; - - // SQL preview page — sourceview5 read-only with sql highlighting. - let lang_manager = sourceview5::LanguageManager::default(); - let sql_buffer = if let Some(lang) = lang_manager.language("sql") { - sourceview5::Buffer::with_language(&lang) - } else { - sourceview5::Buffer::new(None) + /// Read the table's definition in one call, so the tab rebuilds + /// once with columns, indexes and foreign keys from the same moment. + fn fetch_definition(&mut self) { + self.issued_on + .insert(StructureRequestKind::Definition, self.session.link_generation()); + let RequestTicket { stamp, cancel } = self.sequencer.issue(StructureRequestKind::Definition); + let session = self.session.clone(); + let table = self.table_ref(); + let work = async move { + session + .run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| { + let table = table.clone(); + async move { + let definition = lease.fetch_table_definition(&table, options).await?; + Ok(LoadedStructure { + definition, + dialect: lease.dialect(), + }) + } + }) + .await }; - let sql_view = sourceview5::View::with_buffer(&sql_buffer); - sql_view.set_editable(false); - sql_view.set_monospace(true); - sql_view.set_wrap_mode(gtk::WrapMode::Word); - sql_view.set_top_margin(6); - sql_view.set_left_margin(6); - sql_view.set_right_margin(6); - sql_view.set_bottom_margin(6); - apply_sql_scheme(&sql_buffer); - let style_manager = adw::StyleManager::default(); - let buffer_for_theme = sql_buffer.downgrade(); - let theme_handler = HandlerGuard::new( - &style_manager, - style_manager.connect_dark_notify(move |_| { - if let Some(buffer) = buffer_for_theme.upgrade() { - apply_sql_scheme(&buffer); - } - }), - ); - let sql_scroll = gtk::ScrolledWindow::builder().child(&sql_view).vexpand(true).build(); - // Copy SQL toolbar — saves the user from clicking into the - // sourceview, Ctrl+A, Ctrl+C every time they want to paste - // the generated DDL into a different tool. - let copy_sql_btn = gtk::Button::builder() - .icon_name(crate::ui::icons::EDIT_COPY) - .tooltip_text(crate::i18n::gettext("Copy SQL to clipboard")) - .valign(gtk::Align::Center) - .build(); - copy_sql_btn.add_css_class("flat"); - let buffer_for_copy = sql_buffer.clone(); - copy_sql_btn.connect_clicked(move |btn| { - let text = buffer_for_copy.text(&buffer_for_copy.start_iter(), &buffer_for_copy.end_iter(), false); - btn.clipboard().set_text(text.as_str()); - }); - // CenterBox is the native idiom for "leading / centred / - // trailing" toolbar layouts. The earlier hexpand label-spacer - // worked but was a CSS-flexbox-era pattern that fights GTK's - // layout system. - let sql_toolbar = gtk::CenterBox::builder() - .margin_top(6) - .margin_start(6) - .margin_end(6) - .build(); - sql_toolbar.set_end_widget(Some(©_sql_btn)); - let sql_page_box = gtk::Box::builder().orientation(gtk::Orientation::Vertical).build(); - sql_page_box.append(&sql_toolbar); - sql_page_box.append(&sql_scroll); - let sql_page = view_stack.add_titled_with_icon( - &sql_page_box, - Some("sql"), - &crate::i18n::gettext("SQL Preview"), - crate::ui::icons::TEXT_X_GENERIC, - ); - let _ = sql_page; - - // Inline switcher centred above the form. We can't add a - // second AdwHeaderBar to the tab — the wrapper's "Data / - // Structure" header already takes that role. A centred - // ViewSwitcher with margins reads as a section navigation - // without spawning a second toolbar strip. - let view_switcher = adw::ViewSwitcher::builder() - .stack(&view_stack) - .policy(adw::ViewSwitcherPolicy::Wide) - .halign(gtk::Align::Center) - .margin_top(6) - .margin_bottom(6) - .build(); - - // Inner stack swaps between "loading" / "editor" / "error" so - // Edit-mode tabs show a spinner until fetch_structure_data - // resolves. - let inner_stack = gtk::Stack::new(); - inner_stack.set_transition_type(gtk::StackTransitionType::Crossfade); - - // AdwSpinner (libadwaita 1.6+) is the native animated spinner - // — pulses while the introspection round-trip is in flight. - // The earlier `emblem-synchronizing-symbolic` rendered as a - // static sync icon that read as "this could be idle". A - // centred vertical box with spinner + title + dim subtitle - // is the same pattern GNOME Software / Console use for - // in-flight load states. - let loading_spinner = adw::Spinner::builder().width_request(48).height_request(48).build(); - let loading_title = gtk::Label::builder() - .label(crate::i18n::gettext("Loading structure…")) - .build(); - loading_title.add_css_class("title-2"); - let loading_subtitle = gtk::Label::builder() - .label(crate::i18n::gettext("Reading columns, indexes, and foreign keys…")) - .build(); - loading_subtitle.add_css_class("dim-label"); - let loading_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(12) - .halign(gtk::Align::Center) - .valign(gtk::Align::Center) - .vexpand(true) - .build(); - loading_box.append(&loading_spinner); - loading_box.append(&loading_title); - loading_box.append(&loading_subtitle); - inner_stack.add_named(&loading_box, Some("loading")); - - let editor_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(0) - .build(); - // New-mode only: name row at the top of the editor. Edit mode - // hides this — the tab title already shows the name and rename - // is a separate (sidebar context-menu) action that we don't - // want users triggering accidentally by clicking a floating - // text field. The name_entry widget itself is still built and - // stored in the model so update()'s SaveCompleted path can - // `set_text` it when New mode promotes to Edit. - // - // Native pattern: a single AdwEntryRow with title "Name". The - // title floats large when empty (acts as the placeholder) and - // shrinks to a small label above the typed value — matching - // GNOME Settings's text input pattern. The PreferencesGroup - // around it carries only the helper description; no redundant - // "New table" title since the tab title already says so. - let name_entry = adw::EntryRow::builder().title(crate::i18n::gettext("Name")).build(); - name_entry.set_text(&init.table); - let name_row = adw::PreferencesGroup::builder() - .description(crate::i18n::gettext("Add a name and at least one column to save.")) - .margin_top(12) - .margin_bottom(6) - .margin_start(12) - .margin_end(12) - .build(); - name_row.add(&name_entry); - name_row.set_visible(matches!(init.mode, StructureMode::New)); - editor_box.append(&name_row); - editor_box.append(&view_switcher); - editor_box.append(&view_stack); - // Constrain content width on wide windows so forms don't - // stretch to ridiculous proportions (without this a single - // "Add Column" row spans the entire monitor on a wide screen). - // AdwClamp matches the GNOME Settings / Builder pattern; - // 900sp gives room for the column-row attributes (Name, Type, - // Default…) without overflowing on small screens. No outer - // ScrolledWindow because each view in `view_stack` already has - // its own scroller — wrapping again would double-scroll. - let editor_clamp = adw::Clamp::builder() - .maximum_size(900) - .tightening_threshold(700) - .child(&editor_box) - .build(); - inner_stack.add_named(&editor_clamp, Some("editor")); - - let error_status = adw::StatusPage::builder() - .icon_name(crate::ui::icons::DIALOG_ERROR) - .title(crate::i18n::gettext("Couldn't load structure")) - .build(); - // "Try again" — fires another FetchStructure round-trip via - // the existing output channel. Without this, a transient - // network blip on a remote DB forces the user to close and - // reopen the tab. Suggested-action + pill styling matches - // GNOME Software's "Try Again" affordance on its own - // load-failure page. - let retry_button = gtk::Button::builder() - .label(crate::i18n::gettext("Try Again")) - .halign(gtk::Align::Center) - .build(); - retry_button.add_css_class("suggested-action"); - retry_button.add_css_class("pill"); - let sender_for_retry = sender.clone(); - let inner_stack_for_retry = inner_stack.downgrade(); - retry_button.connect_clicked(move |_| { - // Without the loading page the click reads as a no-op until - // StructureLoaded arrives. - if let Some(inner_stack) = inner_stack_for_retry.upgrade() { - inner_stack.set_visible_child_name("loading"); + let tab = self.this.clone(); + let _delivery = crate::runtime::spawn_and_deliver(&self.tasks, work, move |joined| { + let value = joined.unwrap_or_else(|failure| Err(OperationError::from(failure))); + if let Some(tab) = tab.upgrade() { + tab.send(StructureTabInput::Command(StructureCommand::Loaded(Stamped { + stamp, + value, + }))); } - let _ = sender_for_retry.output(StructureTabOutput::FetchStructure); - }); - error_status.set_child(Some(&retry_button)); - inner_stack.add_named(&error_status, Some("error")); - - inner_stack.set_visible_child_name(match init.mode { - StructureMode::New => "editor", - StructureMode::Edit => "loading", }); + } - // Bottom action bar. - let action_bar = gtk::ActionBar::new(); - let pending_label = gtk::Label::builder().build(); - pending_label.add_css_class("dim-label"); - pending_label.set_visible(false); - action_bar.pack_start(&pending_label); - - let discard_button = gtk::Button::builder() - .label(crate::i18n::gettext("Discard")) - .sensitive(false) - .build(); - let save_button = gtk::Button::builder() - .label(crate::i18n::gettext("Save")) - .sensitive(false) - .build(); - save_button.add_css_class("suggested-action"); - let drop_button = gtk::Button::builder() - .label(crate::i18n::gettext("Drop Table…")) - .visible(matches!(init.mode, StructureMode::Edit)) - .build(); - drop_button.add_css_class("destructive-action"); - // Drop sits at the start of the action bar, spatially - // separated from the Discard / Save pair on the end. Mixing - // a destructive action with the primary action invites - // misclicks; HIG groups them by intent. - action_bar.pack_start(&drop_button); - action_bar.pack_end(&save_button); - action_bar.pack_end(&discard_button); + fn show_loading(&self) { + self.inner_stack.set_visible_child_name("loading"); + } - let sender_for_save = sender.clone(); - save_button.connect_clicked(move |_| sender_for_save.input(StructureTabInput::Save)); - let sender_for_discard = sender.clone(); - discard_button.connect_clicked(move |_| sender_for_discard.input(StructureTabInput::Discard)); - let sender_for_drop = sender.clone(); - drop_button.connect_clicked(move |_| sender_for_drop.input(StructureTabInput::DropTableRequested)); + fn on_loaded(&mut self, loaded: LoadedStructure, fx: &mut StructureEffects) { + // Every list goes into `original_*` too, so Discard restores the + // loaded state without a read, including columns the user later + // removed. + let TableDefinition { + columns, + indexes, + foreign_keys, + .. + } = loaded.definition; + *self.dialect.borrow_mut() = Some(loaded.dialect); + *self.original_columns.borrow_mut() = columns.clone(); + *self.columns.borrow_mut() = columns.into_iter().map(DraftColumn::from_definition).collect(); + *self.indexes.borrow_mut() = indexes.clone(); + *self.original_indexes.borrow_mut() = indexes; + *self.foreign_keys.borrow_mut() = foreign_keys.clone(); + *self.original_fks.borrow_mut() = foreign_keys; + self.inner_stack.set_visible_child_name("editor"); + // The snapshots are current again, so the diff counts; it is + // empty now, which also clears anything typed during the read. + self.refetching.set(false); + self.recompute_dirty_state(fx); + fx.input(StructureTabInput::Refresh); + } - // Content + bottom bar. The Data/Structure outer header - // already supplies the toolbar strip — Structure's own - // sub-navigation lives inline inside `editor_box`. - root.set_content(Some(&inner_stack)); - root.add_bottom_bar(&action_bar); + fn on_load_failed(&self, message: &str) { + // Inline on the status page: a modal alert would say the same + // and make the user dismiss it before seeing the page. + self.error_status.set_description(Some(message)); + self.inner_stack.set_visible_child_name("error"); + // A retry from the error page must not find dirty tracking still + // frozen. + self.refetching.set(false); + } - // Wire the table-name entry to push RenameTable / propagate to - // the model. - let suppress_emit = Rc::new(Cell::new(false)); - let sender_for_name = sender.clone(); - let suppress_for_name = suppress_emit.clone(); - name_entry.connect_changed(move |e| { - if suppress_for_name.get() { - return; + fn on_command(&mut self, command: StructureCommand, fx: &mut StructureEffects) { + let StructureCommand::Loaded(Stamped { stamp, value }) = command; + if !self.sequencer.accept(StructureRequestKind::Definition, stamp) { + return; + } + self.failed.record(StructureRequestKind::Definition, &value); + match value { + Ok(loaded) => self.on_loaded(loaded, fx), + Err(error) => { + self.on_load_failed(&crate::ui::error_text::operation_error_message(&error)); + let issued = self.issued_on.get(&StructureRequestKind::Definition).copied(); + if issued.is_some_and(|issued| self.session.link_generation() > issued) { + self.retry_failed(); + } } - sender_for_name.input(StructureTabInput::TableNameEdited(e.text().to_string())); - }); - - // No tracker subscription — dirty state is computed from the - // diff between snapshot + current model on every mutation. - - // Edit mode: kick the App for fetch_structure_data — unless - // the parent (Table tab in Data mode) explicitly deferred us - // to avoid an N-tabs-N-bursts startup stampede. - if matches!(init.mode, StructureMode::Edit) && !init.defer_initial_fetch { - let _ = sender.output(StructureTabOutput::FetchStructure); } + } - let model = StructureTab { - tracker: init.tracker, - schema: init.schema, - original_table_name: Rc::new(RefCell::new(init.table.clone())), - table_name: Rc::new(RefCell::new(init.table)), - mode: Rc::new(RefCell::new(init.mode)), - driver_id: init.driver_id, - columns: Rc::new(RefCell::new(Vec::new())), - indexes: Rc::new(RefCell::new(Vec::new())), - foreign_keys: Rc::new(RefCell::new(Vec::new())), - original_columns: Rc::new(RefCell::new(Vec::new())), - original_indexes: Rc::new(RefCell::new(Vec::new())), - original_fks: Rc::new(RefCell::new(Vec::new())), - dialect: Rc::new(RefCell::new(None)), - inner_stack, - error_status: error_status.clone(), - name_entry, - name_row, - columns_box, - indexes_box, - fks_box, - sql_buffer, - pending_label, - save_button, - discard_button, - drop_button, - last_dirty: Rc::new(RefCell::new(false)), - suppress_emit, - next_column_seq: Rc::new(RefCell::new(1)), - column_popovers: Rc::new(RefCell::new(Vec::new())), - refetching: Rc::new(Cell::new(false)), - _theme_handler: theme_handler, - }; - - // Initial render so New-mode tabs aren't blank. - sender.input(StructureTabInput::Refresh); + /// The session reconnected: a draft takes the new connection's + /// dialect, and a read that failed is asked again. + fn on_link_changed(&mut self, generation: LinkGeneration) { + if generation <= self.link_generation { + return; + } + self.link_generation = generation; + if matches!(*self.mode.borrow(), StructureMode::New) { + *self.dialect.borrow_mut() = self.session.current_dialect(); + self.regenerate_sql_preview_from(&self.current_diff_ops()); + } + self.retry_failed(); + } - ComponentParts { model, widgets: () } + fn retry_failed(&mut self) { + if self.failed.take().contains(&StructureRequestKind::Definition) { + self.show_loading(); + self.fetch_definition(); + } } - fn update(&mut self, msg: Self::Input, sender: ComponentSender) { + fn update(&mut self, msg: StructureTabInput, fx: &mut StructureEffects) { match msg { - StructureTabInput::StructureLoaded { definition, dialect } => { - // Snapshot all three lists into `original_*` slots so - // Discard restores the canonical loaded state without - // a refetch — including columns the user later removed - // (whose `DraftColumn.original` would otherwise vanish - // when the row is dropped from `self.columns`). - let TableDefinition { - columns, - indexes, - foreign_keys, - .. - } = *definition; - *self.dialect.borrow_mut() = Some(dialect); - *self.original_columns.borrow_mut() = columns.clone(); - *self.columns.borrow_mut() = columns.into_iter().map(DraftColumn::from_definition).collect(); - *self.indexes.borrow_mut() = indexes.clone(); - *self.original_indexes.borrow_mut() = indexes; - *self.foreign_keys.borrow_mut() = foreign_keys.clone(); - *self.original_fks.borrow_mut() = foreign_keys; - self.inner_stack.set_visible_child_name("editor"); - // End of refetch window: snapshots are now authoritative, - // so resume diff-based dirty tracking. The recompute call - // sees live model == snapshots and clears the tracker / - // emits DirtyChanged(false), which discards any phantom - // ops the user could have provoked during the window. - self.refetching.set(false); - self.recompute_dirty_state(&sender); - sender.input(StructureTabInput::Refresh); + StructureTabInput::Start => { + if matches!(*self.mode.borrow(), StructureMode::Edit) { + self.fetch_definition(); + } + fx.input(StructureTabInput::Refresh); } - StructureTabInput::DialectReady(dialect) => { - *self.dialect.borrow_mut() = Some(dialect); - self.regenerate_sql_preview_from(&self.current_diff_ops()); + StructureTabInput::Command(command) => self.on_command(command, fx), + StructureTabInput::LinkChanged(generation) => self.on_link_changed(generation), + StructureTabInput::SchemaChanged => { + // Reading the table again replaces the live model, so a + // tab with edits of its own keeps them. + if matches!(*self.mode.borrow(), StructureMode::Edit) && !*self.last_dirty.borrow() { + self.fetch_definition(); + } } - StructureTabInput::LoadFailed(message) => { - // Surface the driver error inline on the StatusPage — - // a modal AdwAlertDialog would duplicate the same - // text and force the user to dismiss it before they - // can see the page. - self.error_status.set_description(Some(&message)); - self.inner_stack.set_visible_child_name("error"); - // Bail out of the refetch window even on failure so - // recompute_dirty_state doesn't stay frozen if the user - // later retries via the error page's reload action. - self.refetching.set(false); + StructureTabInput::Retry => { + // Without the loading page the click reads as a no-op + // until the reply arrives. + self.show_loading(); + self.fetch_definition(); } StructureTabInput::Refresh => { - self.rebuild_columns_view(sender.clone()); - self.rebuild_indexes_view(sender.clone()); - self.rebuild_fks_view(sender.clone()); + self.rebuild_columns_view(); + self.rebuild_indexes_view(); + self.rebuild_fks_view(); self.regenerate_sql_preview_from(&self.current_diff_ops()); } StructureTabInput::TableNameEdited(text) => { @@ -908,7 +824,7 @@ impl SimpleComponent for StructureTab { return; } *self.table_name.borrow_mut() = text; - self.recompute_dirty_state(&sender); + self.recompute_dirty_state(fx); } StructureTabInput::ColumnEdited { index, field } => { let mut cols = self.columns.borrow_mut(); @@ -922,14 +838,9 @@ impl SimpleComponent for StructureTab { ColumnField::Nullable(b) => col.nullable = b, ColumnField::PrimaryKey(b) => { col.primary_key = b; - // Auto-increment requires PK in every supported - // driver (MySQL rejects, Postgres SERIAL implies - // PK). When PK toggles off, the bound `sensitive` - // greys out the Auto checkbox visually but its - // `active` stays true — the model would then - // emit AUTO_INCREMENT in an invalid context. - // Coerce off here so model + tracker stay in - // sync with what the driver will accept. + // Auto-increment requires a key in every supported + // driver; the checkbox only greys out, so the + // model drops it here. if !b { col.auto_increment = false; } @@ -940,14 +851,12 @@ impl SimpleComponent for StructureTab { } let new_col = col.clone(); drop(cols); - // Echo guard — connect_changed / connect_toggled fire - // on programmatic widget set during rebuild, with the - // value already matching the model. Skipping the - // recompute saves a no-op diff pass. + // A rebuild sets each widget to the value the model + // already has, which is no edit. if prev == new_col { return; } - self.recompute_dirty_state(&sender); + self.recompute_dirty_state(fx); } StructureTabInput::AddColumn => { let new_col = DraftColumn { @@ -966,8 +875,8 @@ impl SimpleComponent for StructureTab { comment: None, }; self.columns.borrow_mut().push(new_col); - self.recompute_dirty_state(&sender); - sender.input(StructureTabInput::Refresh); + self.recompute_dirty_state(fx); + fx.input(StructureTabInput::Refresh); } StructureTabInput::RemoveColumn(index) => { { @@ -977,13 +886,13 @@ impl SimpleComponent for StructureTab { } cols.remove(index); } - self.recompute_dirty_state(&sender); - sender.input(StructureTabInput::Refresh); + self.recompute_dirty_state(fx); + fx.input(StructureTabInput::Refresh); } StructureTabInput::AddIndex(index) => { self.indexes.borrow_mut().push(index); - self.recompute_dirty_state(&sender); - sender.input(StructureTabInput::Refresh); + self.recompute_dirty_state(fx); + fx.input(StructureTabInput::Refresh); } StructureTabInput::RemoveIndex(idx_pos) => { { @@ -993,13 +902,13 @@ impl SimpleComponent for StructureTab { } idxs.remove(idx_pos); } - self.recompute_dirty_state(&sender); - sender.input(StructureTabInput::Refresh); + self.recompute_dirty_state(fx); + fx.input(StructureTabInput::Refresh); } StructureTabInput::AddForeignKey(fk) => { self.foreign_keys.borrow_mut().push(fk); - self.recompute_dirty_state(&sender); - sender.input(StructureTabInput::Refresh); + self.recompute_dirty_state(fx); + fx.input(StructureTabInput::Refresh); } StructureTabInput::RemoveForeignKey(idx_pos) => { { @@ -1009,46 +918,10 @@ impl SimpleComponent for StructureTab { } fks.remove(idx_pos); } - self.recompute_dirty_state(&sender); - sender.input(StructureTabInput::Refresh); - } - StructureTabInput::Save => { - let table = self.table_name.borrow().clone(); - let mode = *self.mode.borrow(); - let columns = self.columns.borrow().clone(); - if let Err(message) = validate_save(&table, &columns, mode) { - let _ = sender.output(StructureTabOutput::ShowToast(message)); - return; - } - let ops = self.current_diff_ops(); - match self.materialize(&ops) { - Some(Ok(statements)) if !statements.is_empty() => { - self.save_button.set_sensitive(false); - self.discard_button.set_sensitive(false); - let _ = sender.output(StructureTabOutput::ExecuteTransaction { statements }); - } - Some(Ok(_)) => { - let _ = sender.output(StructureTabOutput::ShowToast(crate::i18n::gettext("Nothing to save."))); - } - None => { - let _ = sender.output(StructureTabOutput::ShowToast(crate::i18n::gettext( - "Connect to a database first.", - ))); - } - Some(Err(BuildDdlError::SqliteNotSupported(detail))) => { - let _ = sender.output(StructureTabOutput::ShowAlert { - title: crate::i18n::gettext("Cannot save"), - body: crate::i18n::gettext_f("SQLite doesn't support: {detail}", &[("detail", detail)]), - }); - } - Some(Err(e)) => { - let _ = sender.output(StructureTabOutput::ShowAlert { - title: crate::i18n::gettext("Cannot save"), - body: format!("{e}"), - }); - } - } + self.recompute_dirty_state(fx); + fx.input(StructureTabInput::Refresh); } + StructureTabInput::Save => self.save(fx), StructureTabInput::Discard => { // Snapshot+diff model: Discard = current state ← // original snapshot. New mode clears everything since @@ -1070,142 +943,168 @@ impl SimpleComponent for StructureTab { *self.indexes.borrow_mut() = self.original_indexes.borrow().clone(); *self.foreign_keys.borrow_mut() = self.original_fks.borrow().clone(); } - self.recompute_dirty_state(&sender); - sender.input(StructureTabInput::Refresh); + self.recompute_dirty_state(fx); + fx.input(StructureTabInput::Refresh); } StructureTabInput::DropTableRequested => { - let _ = sender.output(StructureTabOutput::DropTableRequested { + fx.output(StructureTabOutput::DropTableRequested { schema: self.schema.clone(), table: self.table_name.borrow().clone(), }); } - StructureTabInput::SaveCompleted { new_table_name } => { - if let Some(name) = new_table_name { - *self.mode.borrow_mut() = StructureMode::Edit; - *self.table_name.borrow_mut() = name.clone(); - *self.original_table_name.borrow_mut() = name.clone(); - self.name_entry.set_text(&name); - self.drop_button.set_visible(true); - self.name_row.set_visible(false); - } - if matches!(*self.mode.borrow(), StructureMode::Edit) { - // Eagerly zero the dirty state before the async refetch - // round-trip. Without this, recompute_dirty_state would - // diff the live model against the pre-save snapshot for - // the entire FetchStructure window, producing phantom - // pending ops for changes that were just committed — - // and the close-with-pending dialog would surface them - // if the user closed the tab during the refetch. The - // SQL preview is also reset to "no pending changes" so - // a New→Edit promotion doesn't leave a stale CREATE - // TABLE statement visible in the preview pane until - // StructureLoaded arrives. - self.tracker.clear(); - self.refresh_buttons(0); - self.regenerate_sql_preview_from(&[]); - let mut last = self.last_dirty.borrow_mut(); - if *last { - *last = false; - let _ = sender.output(StructureTabOutput::DirtyChanged(false)); - } - drop(last); - self.refetching.set(true); - let _ = sender.output(StructureTabOutput::FetchStructure); - } - } + StructureTabInput::SaveCompleted { new_table_name } => self.save_completed(new_table_name, fx), StructureTabInput::SaveFailed(message) => { - self.recompute_dirty_state(&sender); - let _ = sender.output(StructureTabOutput::ShowAlert { + self.recompute_dirty_state(fx); + fx.output(StructureTabOutput::ShowAlert { title: crate::i18n::gettext("Save failed"), body: message, }); } } } -} -/// Pick the sourceview5 style scheme matching the active Adwaita -/// light / dark mode. Called on init and on `connect_dark_notify` -/// so the SQL preview tracks system theme switches. -fn apply_sql_scheme(buffer: &sourceview5::Buffer) { - let scheme_name = if adw::StyleManager::default().is_dark() { - "Adwaita-dark" - } else { - "Adwaita" - }; - if let Some(scheme) = sourceview5::StyleSchemeManager::default().scheme(scheme_name) { - buffer.set_style_scheme(Some(&scheme)); - } -} - -#[cfg(test)] -mod tests { - use relm4::{Component, ComponentController, Controller}; - - use super::*; - use crate::test_support::{drain_main_context, first_descendant_of_type}; - - fn new_table() -> Controller { - adw::init().expect("libadwaita initialises under the test backend"); - StructureTab::builder() - .launch(StructureTabInit { - tracker: StructureTrackerHandle::default(), - schema: None, - table: "t".to_owned(), - mode: StructureMode::New, - driver_id: "postgres".to_owned(), - defer_initial_fetch: false, - }) - .detach() + fn save(&self, fx: &mut StructureEffects) { + let table = self.table_name.borrow().clone(); + let mode = *self.mode.borrow(); + let columns = self.columns.borrow().clone(); + if let Err(message) = validate_save(&table, &columns, mode) { + fx.output(StructureTabOutput::ShowToast(message)); + return; + } + let ops = self.current_diff_ops(); + match self.materialize(&ops) { + Some(Ok(statements)) if !statements.is_empty() => { + self.save_button.set_sensitive(false); + self.discard_button.set_sensitive(false); + fx.output(StructureTabOutput::ExecuteTransaction { statements }); + } + Some(Ok(_)) => { + fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext("Nothing to save."))); + } + None => { + fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext( + "Connect to a database first.", + ))); + } + Some(Err(BuildDdlError::SqliteNotSupported(detail))) => { + fx.output(StructureTabOutput::ShowAlert { + title: crate::i18n::gettext("Cannot save"), + body: crate::i18n::gettext_f("SQLite doesn't support: {detail}", &[("detail", detail)]), + }); + } + Some(Err(e)) => { + fx.output(StructureTabOutput::ShowAlert { + title: crate::i18n::gettext("Cannot save"), + body: format!("{e}"), + }); + } + } } - fn scheme_id(buffer: &sourceview5::Buffer) -> Option { - buffer.style_scheme().map(|scheme| scheme.id().to_string()) + fn save_completed(&mut self, new_table_name: Option, fx: &mut StructureEffects) { + if let Some(name) = new_table_name { + *self.mode.borrow_mut() = StructureMode::Edit; + *self.table_name.borrow_mut() = name.clone(); + *self.original_table_name.borrow_mut() = name.clone(); + self.name_entry.set_text(&name); + self.drop_button.set_visible(true); + self.name_row.set_visible(false); + } + if !matches!(*self.mode.borrow(), StructureMode::Edit) { + return; + } + // The diff against the pre-save snapshot would show the changes + // just committed as still pending until the read lands, and a + // close in that window would offer to save them again. So the + // dirty state goes to zero now, and so does a CREATE TABLE left + // in the preview by a draft that just became the table. + self.tracker.clear(); + self.refresh_buttons(0); + self.regenerate_sql_preview_from(&[]); + let mut last = self.last_dirty.borrow_mut(); + if *last { + *last = false; + fx.output(StructureTabOutput::DirtyChanged(false)); + } + drop(last); + self.refetching.set(true); + self.fetch_definition(); } +} - #[gtk4::test] - fn style_handler_disconnected_on_shutdown() { - let style = adw::StyleManager::default(); - style.set_color_scheme(adw::ColorScheme::ForceLight); - let controller = new_table(); - let buffer = controller.model().sql_buffer.clone(); - let light = scheme_id(&buffer); - - drop(controller); - drain_main_context(); - style.set_color_scheme(adw::ColorScheme::ForceDark); - let after = scheme_id(&buffer); - style.set_color_scheme(adw::ColorScheme::Default); +fn boxed_list() -> gtk::ListBox { + let list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .margin_start(12) + .margin_end(12) + .margin_top(6) + .margin_bottom(6) + .build(); + list.add_css_class("boxed-list"); + list +} - assert_eq!(after, light, "a closed structure tab still follows the theme"); +fn clear_box(b: >k::Box) { + while let Some(child) = b.first_child() { + b.remove(&child); } +} - #[gtk4::test] - fn structure_tab_freed_after_drop() { - let controller = new_table(); - controller.emit(StructureTabInput::AddColumn); - drain_main_context(); - let row = first_descendant_of_type::(controller.widget()) - .expect("the added column's row") - .downgrade(); - let widgets: Vec> = { - let model = controller.model(); - [ - model.inner_stack.upcast_ref::(), - model.indexes_box.upcast_ref(), - model.fks_box.upcast_ref(), - controller.widget().upcast_ref(), - ] - .into_iter() - .map(ObjectExt::downgrade) - .collect() - }; +/// A trailing `adw::ButtonRow`, the GNOME pattern for "Add another +/// item" rows in Settings and Builder. +fn append_add_button(list: >k::ListBox, label: &str, on_activate: impl Fn() + 'static) { + let row = adw::ButtonRow::builder() + .title(label) + .start_icon_name(crate::ui::icons::LIST_ADD) + .build(); + row.connect_activated(move |_| on_activate()); + list.append(&row); +} - drop(controller); - drain_main_context(); +/// The first reason the model cannot be saved, in the user's words. +fn validate_save(table_name: &str, columns: &[DraftColumn], mode: StructureMode) -> Result<(), String> { + if matches!(mode, StructureMode::New) && table_name.trim().is_empty() { + return Err(crate::i18n::gettext("Table name is required.")); + } + // In Edit mode too: removing every column would otherwise save as a + // statement that drops them all. + if columns.is_empty() { + return Err(crate::i18n::gettext("At least one column is required.")); + } + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for col in columns { + if col.name.trim().is_empty() { + return Err(crate::i18n::gettext("Every column needs a name.")); + } + if !seen.insert(col.name.as_str()) { + return Err(crate::i18n::gettext_f( + "Duplicate column name: {name}", + &[("name", &col.name)], + )); + } + if col.data_type.trim().is_empty() { + return Err(crate::i18n::gettext_f( + "Column {name} needs a type.", + &[("name", &col.name)], + )); + } + if col.primary_key && col.nullable { + return Err(crate::i18n::gettext_f( + "Primary key columns must be NOT NULL: {name}", + &[("name", &col.name)], + )); + } + } + Ok(()) +} - assert!(row.upgrade().is_none(), "a column row outlived its tab"); - let alive = widgets.iter().filter(|widget| widget.upgrade().is_some()).count(); - assert_eq!(alive, 0, "parts of the structure tab outlived its controller"); +fn apply_sql_scheme(buffer: &sourceview5::Buffer) { + let scheme_name = if adw::StyleManager::default().is_dark() { + "Adwaita-dark" + } else { + "Adwaita" + }; + if let Some(scheme) = sourceview5::StyleSchemeManager::default().scheme(scheme_name) { + buffer.set_style_scheme(Some(&scheme)); } } diff --git a/linux/crates/app/src/ui/structure_tab/structure_command.rs b/linux/crates/app/src/ui/structure_tab/structure_command.rs new file mode 100644 index 0000000000..4da5eeb6bb --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/structure_command.rs @@ -0,0 +1,28 @@ +use std::sync::Arc; + +use tablepro_core::ddl::TableDefinition; +use tablepro_core::dialect::SqlDialect; +use tablepro_session::OperationError; + +use crate::runtime::Stamped; + +/// The one read a structure tab makes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum StructureRequestKind { + Definition, +} + +/// The table as one definition, read in one call, with the dialect of +/// the connection that read it, which the tab spells its DDL in. +#[derive(Debug)] +pub(crate) struct LoadedStructure { + pub definition: TableDefinition, + pub dialect: Arc, +} + +/// A reply to the tab's read, stamped with the request it answers so a +/// reply to a replaced request is dropped. +#[derive(Debug)] +pub(crate) enum StructureCommand { + Loaded(Stamped>), +} diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs new file mode 100644 index 0000000000..0a9c0ec7ff --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -0,0 +1,242 @@ +use std::time::Duration; + +use gtk4::subclass::prelude::ObjectSubclassIsExt; +use tablepro_core::column::{ColumnDefault, ColumnType}; +use tablepro_core::ddl::{ColumnAttributes, ColumnDefinition, TableDefinition}; +use tablepro_core::meta::TableRef; +use tablepro_core::{ColumnInfo, DriverError, LossPhase}; +use tablepro_session::fakes::FakeEstablish; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; + +use super::*; +use crate::test_support::{FakeSession, drain_main_context, first_descendant_of_type, wait_until}; + +const WAIT: Duration = Duration::from_secs(10); + +struct Harness { + tab: Option, + fake: FakeSession, + runtime: Option, +} + +impl Harness { + fn open( + mode: StructureMode, + script: ConnectionScript, + reconnects: impl IntoIterator, + ) -> Self { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, reconnects, runtime.tasks()); + let tab = StructureTab::new(StructureTabInit { + tracker: StructureTrackerHandle::default(), + schema: None, + table: "users".to_owned(), + mode, + driver_id: "postgres".to_owned(), + session: fake.session.clone(), + tasks: runtime.tasks(), + }); + Self { + tab: Some(tab), + fake, + runtime: Some(runtime), + } + } + + fn tab(&self) -> &StructureTab { + self.tab.as_ref().expect("the tab is open") + } + + fn page(&self) -> Option { + self.tab() + .imp() + .inner_stack + .visible_child_name() + .map(|name| name.to_string()) + } + + fn column_names(&self) -> Vec { + self.tab() + .imp() + .model + .borrow() + .as_ref() + .map(|model| { + model + .columns + .borrow() + .iter() + .map(|column| column.name.clone()) + .collect() + }) + .unwrap_or_default() + } +} + +impl Drop for Harness { + fn drop(&mut self) { + self.tab.take(); + drain_main_context(); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} + +fn definition(column: &str) -> TableDefinition { + TableDefinition { + table: TableRef::unqualified("users"), + columns: vec![ColumnDefinition::new( + ColumnInfo { + name: column.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + )], + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } +} + +fn scheme_id(buffer: &sourceview5::Buffer) -> Option { + buffer.style_scheme().map(|scheme| scheme.id().to_string()) +} + +fn sql_buffer(tab: &StructureTab) -> sourceview5::Buffer { + tab.imp() + .sql_view + .buffer() + .downcast::() + .expect("a sourceview buffer") +} + +#[gtk4::test] +fn style_handler_disconnected_on_shutdown() { + let style = adw::StyleManager::default(); + style.set_color_scheme(adw::ColorScheme::ForceLight); + let mut harness = Harness::open(StructureMode::New, ConnectionScript::default(), []); + let buffer = sql_buffer(harness.tab()); + let light = scheme_id(&buffer); + + harness.tab.take(); + drain_main_context(); + style.set_color_scheme(adw::ColorScheme::ForceDark); + let after = scheme_id(&buffer); + style.set_color_scheme(adw::ColorScheme::Default); + + assert_eq!(after, light, "a closed structure tab still follows the theme"); +} + +#[gtk4::test] +fn structure_tab_freed_after_drop() { + let mut harness = Harness::open(StructureMode::New, ConnectionScript::default(), []); + harness.tab().send(StructureTabInput::AddColumn); + drain_main_context(); + let row = first_descendant_of_type::(harness.tab()) + .expect("the added column's row") + .downgrade(); + let widgets: Vec> = { + let imp = harness.tab().imp(); + [ + imp.inner_stack.upcast_ref::(), + imp.indexes_box.upcast_ref(), + imp.fks_box.upcast_ref(), + harness.tab().upcast_ref(), + ] + .into_iter() + .map(ObjectExt::downgrade) + .collect() + }; + + harness.tab.take(); + drain_main_context(); + + assert!(row.upgrade().is_none(), "a column row outlived its tab"); + let alive = widgets.iter().filter(|widget| widget.upgrade().is_some()).count(); + assert_eq!(alive, 0, "parts of the structure tab outlived it"); +} + +#[gtk4::test] +fn a_draft_takes_its_dialect_from_the_session() { + let harness = Harness::open(StructureMode::New, ConnectionScript::default(), []); + harness.tab().send(StructureTabInput::AddColumn); + drain_main_context(); + + let buffer = sql_buffer(harness.tab()); + let preview = buffer.text(&buffer.start_iter(), &buffer.end_iter(), false); + assert!(preview.starts_with("CREATE TABLE"), "{preview}"); +} + +#[gtk4::test] +fn failed_structure_load_refetches_after_link_replace() { + let mut first = ConnectionScript::default(); + first + .fetch_table_definition + .always(FakeReply::err(DriverError::ConnectionLost { + during: LossPhase::Statement, + })); + let mut second = ConnectionScript::default(); + second.fetch_table_definition.always(FakeReply::ok(definition("email"))); + let harness = Harness::open(StructureMode::Edit, first, [FakeEstablish::Connect(second)]); + + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure after the reconnect"); + + assert_eq!(harness.fake.session.link_generation().get(), 1); + assert_eq!( + CallCounters::get(&harness.fake.replacement().counters().fetch_table_definition), + 1 + ); + assert_eq!(harness.column_names(), ["email"]); +} + +#[gtk4::test] +fn a_loaded_structure_is_not_read_again_on_link_replace() { + let mut first = ConnectionScript::default(); + first.fetch_table_definition.always(FakeReply::ok(definition("id"))); + let harness = Harness::open( + StructureMode::Edit, + first, + [FakeEstablish::Connect(ConnectionScript::default())], + ); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + + harness.fake.connection.mark_lost(); + wait_until(WAIT, || harness.fake.session.link_generation().get() == 1).expect("the reconnect"); + std::thread::sleep(Duration::from_millis(200)); + drain_main_context(); + + assert_eq!( + CallCounters::get(&harness.fake.replacement().counters().fetch_table_definition), + 0 + ); + assert_eq!(harness.column_names(), ["id"]); +} + +#[gtk4::test] +fn a_schema_change_leaves_a_tab_with_edits_alone() { + let mut script = ConnectionScript::default(); + script.fetch_table_definition.always(FakeReply::ok(definition("id"))); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let counters = harness.fake.connection.counters(); + + harness.tab().send(StructureTabInput::AddColumn); + harness.tab().send(StructureTabInput::SchemaChanged); + drain_main_context(); + std::thread::sleep(Duration::from_millis(100)); + drain_main_context(); + + assert_eq!(CallCounters::get(&counters.fetch_table_definition), 1); + assert_eq!(harness.column_names(), ["id", "column_1"]); +} diff --git a/linux/crates/app/src/ui/structure_tab_dialogs.rs b/linux/crates/app/src/ui/structure_tab_dialogs.rs index 6a8a5521da..5699fe5ac0 100644 --- a/linux/crates/app/src/ui/structure_tab_dialogs.rs +++ b/linux/crates/app/src/ui/structure_tab_dialogs.rs @@ -18,15 +18,16 @@ use std::cell::RefCell; use std::rc::Rc; -use relm4::adw::prelude::*; -use relm4::{ComponentSender, adw, gtk}; +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; use tablepro_core::ddl::ReferentialAction; use tablepro_core::meta::IndexKey; use tablepro_core::sql_ddl::DraftColumn; use tablepro_core::{ForeignKeyInfo, IndexInfo}; -use super::structure_tab::{StructureTab, StructureTabInput, StructureTabOutput}; +use super::structure_tab::{StructureTabInput, StructureTabOutput, StructureTabSender}; /// `(column name, checkbox)` pairs, shared between the dialog body /// and the submit handler so the latter can collect which columns the @@ -124,11 +125,7 @@ fn build_column_checklist(columns: &[DraftColumn]) -> (gtk::ListBox, ColumnCheck (list, checks) } -pub(super) fn present_index_dialog( - parent: >k::Widget, - columns: &[DraftColumn], - sender: ComponentSender, -) { +pub(super) fn present_index_dialog(parent: >k::Widget, columns: &[DraftColumn], sender: StructureTabSender) { let (dialog, body, submit_btn) = build_form_dialog(&crate::i18n::gettext("Add Index"), &crate::i18n::gettext("Add")); @@ -162,7 +159,7 @@ pub(super) fn present_index_dialog( if name.trim().is_empty() { // Toast instead of silent no-op so the user knows why // their click didn't land. - let _ = sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( + sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( "Index name is required.", ))); return; @@ -173,7 +170,7 @@ pub(super) fn present_index_dialog( .filter_map(|(n, c)| if c.is_active() { Some(n.clone()) } else { None }) .collect(); if cols.is_empty() { - let _ = sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( + sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( "Select at least one column.", ))); return; @@ -201,7 +198,7 @@ pub(super) fn present_fk_dialog( parent: >k::Widget, columns: &[DraftColumn], driver_id: &str, - sender: ComponentSender, + sender: StructureTabSender, ) { let fk_actions = tablepro_core::sql_ddl::supported_fk_actions(driver_id); let action_labels = action_labels(fk_actions); @@ -257,13 +254,13 @@ pub(super) fn present_fk_dialog( let name = name_row.text().to_string(); let ref_table = ref_table_row.text().to_string(); if name.trim().is_empty() { - let _ = sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( + sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( "Foreign key name is required.", ))); return; } if ref_table.trim().is_empty() { - let _ = sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( + sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( "Reference table is required.", ))); return; @@ -274,7 +271,7 @@ pub(super) fn present_fk_dialog( .filter_map(|(n, c)| if c.is_active() { Some(n.clone()) } else { None }) .collect(); if cols.is_empty() { - let _ = sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( + sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( "Select at least one source column.", ))); return; @@ -286,7 +283,7 @@ pub(super) fn present_fk_dialog( .filter(|s| !s.is_empty()) .collect(); if ref_cols.is_empty() { - let _ = sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( + sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( "Reference columns are required.", ))); return; @@ -295,7 +292,7 @@ pub(super) fn present_fk_dialog( // is structurally invalid SQL. Drivers reject it, but with an // opaque error after Save instead of an inline guard. if ref_cols.len() != cols.len() { - let _ = sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( + sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( "Source and reference column counts must match.", ))); return; diff --git a/linux/data/resources/resources.gresource.xml b/linux/data/resources/resources.gresource.xml index 3ff6df01d0..96c11d2e9e 100644 --- a/linux/data/resources/resources.gresource.xml +++ b/linux/data/resources/resources.gresource.xml @@ -10,6 +10,7 @@ ui/sql-editor.ui ui/sqlite-file-row.ui ui/ssh-section.ui + ui/structure-tab.ui ui/tls-section.ui icons/scalable/actions/table-relation-symbolic.svg icons/scalable/actions/statement-pending-symbolic.svg diff --git a/linux/data/resources/ui/structure-tab.ui b/linux/data/resources/ui/structure-tab.ui new file mode 100644 index 0000000000..53bdc08616 --- /dev/null +++ b/linux/data/resources/ui/structure-tab.ui @@ -0,0 +1,252 @@ + + + + + + + diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index d24d5ec37d..4c6a8f41ab 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -134,8 +134,11 @@ crates/app/src/ui/ssh_inputs.rs crates/app/src/ui/ssh_section.rs crates/app/src/ui/structure_tab/columns.rs crates/app/src/ui/structure_tab/fks.rs +crates/app/src/ui/structure_tab/imp.rs crates/app/src/ui/structure_tab/indexes.rs crates/app/src/ui/structure_tab/mod.rs +crates/app/src/ui/structure_tab/structure_command.rs +crates/app/src/ui/structure_tab/tests.rs crates/app/src/ui/structure_tab_dialogs.rs crates/app/src/ui/style_scheme_grid.rs crates/app/src/ui/style_scheme_resolver.rs @@ -155,4 +158,5 @@ data/resources/ui/results-pane.ui data/resources/ui/sql-editor.ui data/resources/ui/sqlite-file-row.ui data/resources/ui/ssh-section.ui +data/resources/ui/structure-tab.ui data/resources/ui/tls-section.ui From 63caf3e60df95ad3964caa5b277a48969c519895 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 19:29:11 +0700 Subject: [PATCH 090/178] docs(linux): describe the schema catalog and tab-owned fetches --- linux/ARCHITECTURE.md | 8 ++++++-- linux/CHANGELOG.md | 3 +++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 92cf8c6986..b3a8a734cf 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -193,6 +193,8 @@ On Linux the system store is the distribution's CA bundle. Under Flatpak it is t A window holds one `Session`. Every use of its connection goes through the `OperationRunner`, which adds no timer of its own: the deadline travels in `CallOptions` so the driver stops the statement on the server. The session's monitor probes every 30 s and calls the connection lost after three failed probes in a row, or as soon as `Connection::lost()` fires, which a driver resolves only when the link is gone, never for one dead backend in its pool. It then reconnects on a backoff from 5 s to 60 s. A failure that a retry cannot fix (authentication, TLS, configuration, a host key to review) parks the session until the user acts. A read that failed because its connection was lost runs once more on the replacement link; a save never does. +The session's `SchemaCatalog` holds what the window knows about the database: the schemas, each scope's objects and each table's columns. Callers that ask for the same thing at once share one fetch, a fetch runs as its own task so a view that closes does not stop it, and a failed refresh keeps the list it had. A reconnect commits the catalog the new connection read and marks the rest stale; `invalidate_table` does the same for one table after a schema change. The sidebar and the editor's completion words follow the default schema's list through a watch the window drops before the session closes. + ## Workspace tab system The active connection drives a single `AdwTabView` hosting heterogeneous tabs. The `App` component owns the strip; tabs are typed via the `WorkspaceTab` enum: @@ -206,9 +208,11 @@ pub enum WorkspaceTab { } ``` -Each tab is a Relm4 `Controller` whose widget the `AdwTabView` adopts. `App` keeps a `HashMap` keyed by the tab's UUID; the canonical display order comes from `tab_view.pages()` (drag-reorderable). The UUID is stashed on the `AdwTabPage` via `glib::Quark` qdata so close / right-click actions can recover it. +Each tab is a gtk-rs widget with a composite template, which the `AdwTabView` adopts. `App` keeps a `HashMap` keyed by the tab's UUID; the canonical display order comes from `tab_view.pages()` (drag-reorderable). The UUID is stashed on the `AdwTabPage` via `glib::Quark` qdata so close / right-click actions can recover it. + +App routing is hub-and-spoke: a tab emits signals, and the closures App connects tag them with the tab's UUID and re-emit them as `AppMsg` inputs. App's `update` looks up the slot and calls back into the tab. This keeps the tabs ignorant of each other and gives App one place to enforce cross-tab invariants (reload the tabs on a table whose schema changed, close all tabs for a dropped table, etc.). -App routing is hub-and-spoke: every per-tab event becomes an output that App's `forward(...)` closure tags with the tab's UUID and re-emits as an `AppMsg::*ForTab(id, ...)`. App's `update` looks up the slot and dispatches back to the controller's input. This keeps the per-tab controllers ignorant of each other and gives App one place to enforce cross-tab invariants (refetch siblings after Save, close all tabs for a dropped table, etc.). +A browse or structure tab reads its own data through the session. A `RequestSequencer` keeps one request of each kind current: a new page stops the one it replaces, and a reply to a replaced request is dropped. A page and a count wait for the table's columns from the catalog. A read that failed is asked again once the session reconnects, while a tab whose reads all landed keeps its rows. Closing a tab stops its reads, so the server drops the statement. Reopening a closed tab uses a 10-deep `VecDeque` snapshot taken in `finish_close_workspace_tab` before the slot is dropped. The stack clears on disconnect because descriptors reference tables in the active connection. diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 41c8616290..123e7a0aa7 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -41,6 +41,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Exported arrays wrote each row of a multi-dimensional array as a quoted string. - Markdown export broke its table when a column name held a pipe. - Closed tabs, earlier result pages and structure edits kept using memory until TablePro quit. +- A page read before a filter, sort or page change could replace the newer page when it arrived late. +- Closing a table tab left its query running on the server until it finished. +- Refresh did not show a column added to the table since the tab opened. ### Security From 4d03722021ee242a6477a00845e4e700eb680226 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 19:33:41 +0700 Subject: [PATCH 091/178] fix(linux): rebuild the grid when a table's columns change --- linux/CHANGELOG.md | 1 + linux/crates/app/src/ui/browse_tab/mod.rs | 21 +++++++---- linux/crates/app/src/ui/browse_tab/tests.rs | 41 +++++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 123e7a0aa7..33c61bc2f8 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -44,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A page read before a filter, sort or page change could replace the newer page when it arrived late. - Closing a table tab left its query running on the server until it finished. - Refresh did not show a column added to the table since the tab opened. +- A table tab kept the old header of a column renamed in the Structure tab. ### Security diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 3f8cdd0626..3d9668d11b 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -179,7 +179,7 @@ impl BrowseTab { loading_page: imp.loading_page.get(), error_page: imp.error_page.get(), current_column_view: None, - rendered_column_count: Cell::new(0), + rendered_columns: Vec::new(), read_only_banner: imp.read_only_banner.get(), no_pk_banner: imp.no_pk_banner.get(), was_dirty: Cell::new(false), @@ -265,8 +265,9 @@ impl BrowseTab { } } + /// `None` once the tab is closed, and while it is handling an input. fn read(&self, read: impl FnOnce(&BrowseModel) -> T) -> Option { - self.imp().model.borrow().as_ref().map(read) + self.imp().model.try_borrow().ok()?.as_ref().map(read) } pub fn snapshot(&self) -> Option { @@ -576,9 +577,10 @@ pub(crate) struct BrowseModel { loading_page: adw::StatusPage, error_page: adw::StatusPage, current_column_view: Option, - /// Column count the current view was built for; a page with the - /// same count reuses the view and only swaps its rows. - rendered_column_count: Cell, + /// The columns the current view was built for. A page under the + /// same columns reuses the view and only swaps its rows; a schema + /// change that renames or retypes one builds the view again. + rendered_columns: Vec, /// Banners are for states the user cannot clear by saving; pending /// edits show in the footer and the tab title instead. read_only_banner: adw::Banner, @@ -1142,7 +1144,7 @@ impl BrowseModel { ); self.current_selection = Some(selection); self.current_column_view = Some(column_view.clone()); - self.rendered_column_count.set(self.current_columns.len()); + self.rendered_columns = self.current_columns.clone(); // The selection model is new per rebuild, so the previous // handler goes with the old one. @@ -1171,7 +1173,7 @@ impl BrowseModel { } fn column_view_matches_current_columns(&self) -> bool { - self.current_column_view.is_some() && self.rendered_column_count.get() == self.current_columns.len() + self.current_column_view.is_some() && self.rendered_columns == self.current_columns } /// New rows under the same columns. The drafts go back on top so @@ -1353,6 +1355,11 @@ impl BrowseModel { } fn on_columns_loaded(&mut self, columns: Vec, fx: &mut Effects) { + // A page read under other columns would put their names in the + // headers, so the grid waits for the page read under these. + if self.current_columns != columns { + self.current_result = None; + } self.current_columns = columns.clone(); if let Some(result) = self.current_result.as_mut() && result.columns.is_empty() diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 8cea84a95b..0c73523c64 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -527,6 +527,47 @@ fn schema_change_reads_the_columns_again_then_the_page() { assert_eq!(CallCounters::get(&counters.fetch_columns), 2); } +fn header_titles(tab: &BrowseTab) -> Vec { + let Some(view) = crate::test_support::first_descendant_of_type::(tab) else { + return Vec::new(); + }; + let columns = view.columns(); + (0..columns.n_items()) + .filter_map(|position| columns.item(position).and_downcast::()) + .filter_map(|column| column.title().map(|title| title.to_string())) + .collect() +} + +#[gtk4::test] +fn a_renamed_column_builds_the_grid_again() { + let renamed = [column("id", true), column("full_name", false)]; + let mut script = serving_table(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::ok(renamed.to_vec())); + script + .query_bound + .when_sql_contains( + "full_name", + FakeReply::ok(QueryResult::new( + renamed.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(0), Value::Text("Ada".to_owned())]], + )), + ) + .always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || header_titles(harness.tab()).contains(&"name".to_owned())).expect("the first grid"); + + harness + .session + .invalidate_table(&tablepro_core::TableRef::unqualified("users")); + harness.tab().send(BrowseTabInput::SchemaChanged); + + wait_until(WAIT, || header_titles(harness.tab()).contains(&"full_name".to_owned())) + .expect("the grid kept the old column's header"); +} + #[gtk4::test] fn browse_tab_and_filter_strip_freed_after_drop() { let mut script = serving_table(); From 2aba5deeb68259171e703bbef79a3a1d6b733e4b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 19:35:25 +0700 Subject: [PATCH 092/178] test(linux): cover the signals the browse and structure tabs emit --- linux/crates/app/src/ui/browse_tab/tests.rs | 27 +++++++++++++++++++ .../crates/app/src/ui/structure_tab/tests.rs | 17 ++++++++++++ 2 files changed, 44 insertions(+) diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 0c73523c64..8a25ecbbba 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -568,6 +568,33 @@ fn a_renamed_column_builds_the_grid_again() { .expect("the grid kept the old column's header"); } +#[gtk4::test] +fn export_and_save_reach_the_window_as_signals() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let exported = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let saved = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let export_sink = exported.clone(); + harness.tab().connect_export_results(move |_, request| { + export_sink.borrow_mut().push((request.name, request.result.rows.len())); + }); + let save_sink = saved.clone(); + harness.tab().connect_save_requested(move |_, request| { + save_sink + .borrow_mut() + .push((request.table, request.changes.changes.changes.len())); + }); + + harness.tab().send(BrowseTabInput::ExportCurrentPage); + harness.tab().send(BrowseTabInput::InsertRow); + harness.tab().send(BrowseTabInput::CommitSave); + + assert_eq!(*exported.borrow(), [("users".to_owned(), 2)]); + assert_eq!(*saved.borrow(), [(tablepro_core::TableRef::unqualified("users"), 1)]); +} + #[gtk4::test] fn browse_tab_and_filter_strip_freed_after_drop() { let mut script = serving_table(); diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index 0a9c0ec7ff..9e35966c1e 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -240,3 +240,20 @@ fn a_schema_change_leaves_a_tab_with_edits_alone() { assert_eq!(CallCounters::get(&counters.fetch_table_definition), 1); assert_eq!(harness.column_names(), ["id", "column_1"]); } + +#[gtk4::test] +fn drop_asks_the_window_with_the_table_it_shows() { + let mut script = ConnectionScript::default(); + script.fetch_table_definition.always(FakeReply::ok(definition("id"))); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let asked = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = asked.clone(); + harness + .tab() + .connect_drop_table_requested(move |_, schema, table| sink.borrow_mut().push((schema, table))); + + harness.tab().imp().drop_button.emit_clicked(); + + assert_eq!(*asked.borrow(), [(None, "users".to_owned())]); +} From 93723267a8694227a32c96ca5776086815156e22 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 20:14:04 +0700 Subject: [PATCH 093/178] fix(linux): read a table's columns from the server when its tab opens --- linux/crates/app/src/ui/browse_tab/mod.rs | 4 +- linux/crates/app/src/ui/browse_tab/tests.rs | 84 ++++++++++++++++----- 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 3d9668d11b..c8bc6d9c84 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -1399,7 +1399,9 @@ impl BrowseModel { // A saved filter shows on the button before the first page // returns. self.refresh_filter_chrome(); - self.reload_from_columns(false); + // DDL from the SQL editor or another client never reaches + // the catalog, so a tab that opens asks the server. + self.reload_from_columns(true); } BrowseTabInput::Command(command) => self.on_command(command, fx), BrowseTabInput::LinkChanged(generation) => self.on_link_changed(generation, fx), diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 8a25ecbbba..5bedc2e391 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -337,10 +337,11 @@ fn serving_table() -> ConnectionScript { struct Harness { tab: Option, + others: Vec, session: Arc, connection: Arc, fake: FakeSession, - _root: tempfile::TempDir, + root: tempfile::TempDir, runtime: Option, } @@ -349,29 +350,16 @@ impl Harness { libadwaita::init().expect("libadwaita initialises under the test backend"); crate::register_test_resources(); let runtime = crate::test_support::test_runtime(); - let tasks = runtime.tasks(); - let fake = FakeSession::start(script, reconnects, tasks.clone()); + let fake = FakeSession::start(script, reconnects, runtime.tasks()); let root = tempfile::tempdir().expect("tempdir"); - let tab = BrowseTab::new(BrowseTabInit { - tracker: crate::services::tab_tracker_handle::TabTrackerHandle::default(), - schema: None, - table: "users".to_owned(), - connection_id: Some(uuid::Uuid::new_v4()), - read_only: false, - page_size: 100, - initial_offset: 0, - initial_sort: None, - column_widths: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), &tasks), - filter_settings: crate::persistence::FilterSettingsStore::load(root.path().join("filters.json"), &tasks), - session: fake.session.clone(), - tasks, - }); + let tab = users_tab(&fake.session, &runtime, &root); Self { tab: Some(tab), + others: Vec::new(), session: fake.session.clone(), connection: fake.connection.clone(), fake, - _root: root, + root, runtime: Some(runtime), } } @@ -380,6 +368,14 @@ impl Harness { self.tab.as_ref().expect("the tab is open") } + /// Another tab on the same table and session. + fn open_another(&mut self) -> BrowseTab { + let runtime = self.runtime.as_ref().expect("the runtime is running"); + let tab = users_tab(&self.session, runtime, &self.root); + self.others.push(tab.clone()); + tab + } + fn shown(&self) -> Vec { self.tab() .snapshot() @@ -392,9 +388,28 @@ impl Harness { } } +fn users_tab(session: &Arc, runtime: &crate::runtime::AppRuntime, root: &tempfile::TempDir) -> BrowseTab { + let tasks = runtime.tasks(); + BrowseTab::new(BrowseTabInit { + tracker: crate::services::tab_tracker_handle::TabTrackerHandle::default(), + schema: None, + table: "users".to_owned(), + connection_id: Some(uuid::Uuid::new_v4()), + read_only: false, + page_size: 100, + initial_offset: 0, + initial_sort: None, + column_widths: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), &tasks), + filter_settings: crate::persistence::FilterSettingsStore::load(root.path().join("filters.json"), &tasks), + session: session.clone(), + tasks, + }) +} + impl Drop for Harness { fn drop(&mut self) { self.tab.take(); + self.others.clear(); drain_main_context(); if let Some(runtime) = self.runtime.take() { runtime.shutdown(); @@ -568,6 +583,39 @@ fn a_renamed_column_builds_the_grid_again() { .expect("the grid kept the old column's header"); } +#[gtk4::test] +fn a_tab_that_opens_reads_the_columns_the_table_has_now() { + let altered = [column("id", true), column("name", false), column("email", false)]; + let mut script = serving_table(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::ok(altered.to_vec())); + script + .query_bound + .when_sql_contains( + "email", + FakeReply::ok(QueryResult::new( + altered.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![ + Value::Int(0), + Value::Text("Ada".to_owned()), + Value::Text("ada@example.com".to_owned()), + ]], + )), + ) + .always(FakeReply::ok(rows(&["Ada"]))); + let mut harness = Harness::open(script, []); + wait_until(WAIT, || header_titles(harness.tab()).contains(&"name".to_owned())).expect("the first grid"); + + // The column was added by a client the catalog never hears from. + let reopened = harness.open_another(); + + wait_until(WAIT, || header_titles(&reopened).contains(&"email".to_owned())) + .expect("the new tab was built from the columns cached when the first one opened"); + assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 2); +} + #[gtk4::test] fn export_and_save_reach_the_window_as_signals() { let mut script = serving_table(); From dc0e172683d48b2473d1535d4953c62136c82cab Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 20:14:11 +0700 Subject: [PATCH 094/178] fix(linux): keep a table tab's rows when a refresh of them fails --- linux/CHANGELOG.md | 1 + linux/crates/app/src/ui/browse_tab/fetch.rs | 4 +- linux/crates/app/src/ui/browse_tab/mod.rs | 40 ++++++++++++++++++ linux/crates/app/src/ui/browse_tab/tests.rs | 45 +++++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 33c61bc2f8..3d59ce5e9e 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Closing a table tab left its query running on the server until it finished. - Refresh did not show a column added to the table since the tab opened. - A table tab kept the old header of a column renamed in the Structure tab. +- A refresh that failed replaced the rows in a table tab with an error page. ### Security diff --git a/linux/crates/app/src/ui/browse_tab/fetch.rs b/linux/crates/app/src/ui/browse_tab/fetch.rs index b6b9df5bc9..61dd4bbb40 100644 --- a/linux/crates/app/src/ui/browse_tab/fetch.rs +++ b/linux/crates/app/src/ui/browse_tab/fetch.rs @@ -232,7 +232,7 @@ impl BrowseModel { } Err(error) => { tracing::warn!(%error, "could not read the table's columns"); - self.show_error(&crate::ui::error_text::operation_error_message(&error)); + self.on_read_failed(&crate::ui::error_text::operation_error_message(&error), fx); self.retry_if_link_moved(BrowseRequestKind::Columns, fx); } } @@ -248,7 +248,7 @@ impl BrowseModel { self.on_rows_loaded(offset, reply.result, fx); } Err(error) => { - self.show_error(&crate::ui::error_text::operation_error_message(&error)); + self.on_read_failed(&crate::ui::error_text::operation_error_message(&error), fx); self.retry_if_link_moved(BrowseRequestKind::Page, fx); } } diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index c8bc6d9c84..c6f06bc516 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -172,6 +172,7 @@ impl BrowseTab { current_filter: initial_filter, current_columns: Vec::new(), current_result: None, + shown_view: None, current_selection: None, current_total_rows: None, inner_stack: imp.inner_stack.get(), @@ -531,6 +532,15 @@ impl Effects { } } +/// Which page of the table a read asks for. +#[derive(Debug, Clone, PartialEq)] +struct PageView { + filter: tablepro_core::FilterSet, + sort: Option<(usize, bool)>, + offset: u64, + limit: u64, +} + /// The tab's state and the widgets it updates. pub(crate) struct BrowseModel { this: glib::WeakRef, @@ -569,6 +579,9 @@ pub(crate) struct BrowseModel { current_filter: tablepro_core::FilterSet, current_columns: Vec, current_result: Option, + /// What `current_result` was read under. A read that fails while + /// asking for that same page again leaves it on screen. + shown_view: Option, current_selection: Option, current_total_rows: Option, @@ -1333,6 +1346,7 @@ impl BrowseModel { /// them before its own page arrives. fn show_error(&mut self, message: &str) { self.current_result = None; + self.shown_view = None; self.current_total_rows = None; self.paginator_label.set_label(""); self.first_button.set_sensitive(false); @@ -1351,14 +1365,40 @@ impl BrowseModel { result.columns = result_columns(&self.current_columns); } self.current_result = Some(result); + self.shown_view = Some(self.page_view()); self.render_grid_if_ready(fx); } + fn page_view(&self) -> PageView { + PageView { + filter: self.current_filter.clone(), + sort: self.current_sort, + offset: self.current_offset, + limit: self.page_size, + } + } + + /// A read failed. When it asked again for the page on screen, as F5 + /// and a schema change do, the page stays and a toast says why; any + /// other page on screen no longer matches what the tab asks for. + fn on_read_failed(&mut self, message: &str, fx: &mut Effects) { + if self.current_result.is_some() && self.shown_view.as_ref() == Some(&self.page_view()) { + self.refresh_inner_stack_visibility(); + fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext_f( + "Couldn't refresh the rows: {error}", + &[("error", message)], + ))); + return; + } + self.show_error(message); + } + fn on_columns_loaded(&mut self, columns: Vec, fx: &mut Effects) { // A page read under other columns would put their names in the // headers, so the grid waits for the page read under these. if self.current_columns != columns { self.current_result = None; + self.shown_view = None; } self.current_columns = columns.clone(); if let Some(result) = self.current_result.as_mut() diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 5bedc2e391..f5388435ba 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -616,6 +616,51 @@ fn a_tab_that_opens_reads_the_columns_the_table_has_now() { assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 2); } +fn toasts(tab: &BrowseTab) -> std::rc::Rc>> { + let toasts = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = toasts.clone(); + tab.connect_toast(move |_, text| sink.borrow_mut().push(text)); + toasts +} + +#[gtk4::test] +fn a_failed_refresh_keeps_the_rows_on_screen() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(rows(&["kept"]))) + .always(FakeReply::err(DriverError::server("denied"))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); + let toasts = toasts(harness.tab()); + let counters = harness.connection.counters(); + + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || !toasts.borrow().is_empty()).expect("the failed refresh was not reported"); + + assert_eq!(CallCounters::get(&counters.query_bound), 2); + assert_eq!(harness.shown(), text(&["kept"]), "the failed refresh dropped the rows"); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); + assert!(toasts.borrow()[0].contains("denied"), "{:?}", toasts.borrow()); +} + +#[gtk4::test] +fn a_failed_page_under_a_new_filter_shows_the_error() { + let mut script = serving_table(); + script + .query_bound + .when_sql_contains("IS NOT NULL", FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(rows(&["unfiltered"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["unfiltered"])).expect("the first page"); + + harness.tab().send(BrowseTabInput::FilterApplied(not_null_name())); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")) + .expect("rows read without the filter stayed under it"); + assert!(harness.shown().is_empty()); +} + #[gtk4::test] fn export_and_save_reach_the_window_as_signals() { let mut script = serving_table(); From 4be3ebf4ce670bf8982bbaa0b3c93022a7ad7dff Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 20:14:20 +0700 Subject: [PATCH 095/178] style(linux): clear em dashes from window and structure comments Also puts connect_editor's doc comment back on connect_editor. --- linux/crates/app/src/ui/app/connection.rs | 10 ++-- linux/crates/app/src/ui/app/mod.rs | 60 +++++++++---------- linux/crates/app/src/ui/app/status_pages.rs | 4 +- linux/crates/app/src/ui/app/structure.rs | 16 ++--- linux/crates/app/src/ui/app/workspace_tabs.rs | 32 +++++----- .../app/src/ui/structure_tab/columns.rs | 14 ++--- .../app/src/ui/structure_tab/indexes.rs | 6 +- .../app/src/ui/structure_tab_dialogs.rs | 14 ++--- 8 files changed, 78 insertions(+), 78 deletions(-) diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index 3504cb5e72..9f98e2f537 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -434,7 +434,7 @@ impl App { // Persist + tear down workspace tabs before dropping the // session (persist needs the active connection id). self.teardown_workspace_tabs(); - // Drop reopen-stack entries — they reference tables in the + // Drop reopen-stack entries: they reference tables in the // connection we're about to release. Reopening one against a // different connection would target a non-existent table. self.clear_closed_tabs_stack(); @@ -507,7 +507,7 @@ impl App { // Connection deletion wipes the saved entry and ALL associated // keyring credentials (db password, SSH password, SSH passphrase). // Irreversible (no Undo can recover keyring entries) so we - // confirm unconditionally — the previous `confirm_destructive` + // confirm unconditionally; the previous `confirm_destructive` // preference gate let users skip it, but per HIG (and GNOME // Files' bookmark-delete behaviour) destructive keyring writes // need confirmation regardless of preferences. @@ -868,12 +868,12 @@ impl App { pub(super) fn refresh_window_title(&self) { // Subtitle: " · " when connected, empty // otherwise. The active table goes in the tab title (where it - // already lives) — duplicating it in the WindowTitle subtitle + // already lives); duplicating it in the WindowTitle subtitle // both overruns the slot's intended ~7-word capacity and // pretends the subtitle is the canonical "where am I?" widget // when the tab strip already serves that role. Matches GNOME // Builder (subtitle = branch name only) and Text Editor - // (subtitle = filename only) — short, single-purpose. + // (subtitle = filename only): short, single-purpose. let metadata = self.context.active.metadata(); let connection_name = metadata.as_ref().map(|m| m.name.as_str()); let active = self.selected_browse_slot_table(); @@ -900,7 +900,7 @@ impl App { // Sidebar header acts as a breadcrumb: the title shows the // active connection name when connected, falling back to the // generic "Tables" label on the welcome screen. Subtitle stays - // empty — the driver / host already lives in the main header. + // empty: the driver / host already lives in the main header. match connection_name { Some(name) => { self.sidebar_title.set_title(name); diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 1692842196..9edb241d7f 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -105,7 +105,7 @@ pub struct App { row_op_spinner: adw::Spinner, read_only_badge: gtk::Label, table_search: gtk::SearchEntry, - /// Outer Stack inside `content_holder` — swaps between `"empty"` + /// Outer Stack inside `content_holder`: swaps between `"empty"` /// (AdwStatusPage "Select a table") and `"tabs"` (the unified /// AdwTabOverview hosting both Browse and Editor sub-components). workspace_outer_stack: gtk::Stack, @@ -131,7 +131,7 @@ pub struct App { welcome_view: Controller, /// Driver id is connection-wide, not per-tab. current_driver_id: Option, - /// All tables in the current connection — fed into `schema_buffer` + /// All tables in the current connection, fed into `schema_buffer` /// for the editor's autocomplete; not the per-tab columns. table_names: Vec, /// Read-only flag is connection-wide; fanned out to every BrowseTab @@ -144,7 +144,7 @@ pub struct App { /// Tabs the user picked "Save" on in a close-confirmation dialog, /// counted by remaining saves before the close fires. A Table tab /// with both browse-dirty AND structure-dirty dispatches two saves - /// (one of each kind) so its entry starts at 2 — the first + /// (one of each kind) so its entry starts at 2: the first /// completion decrements to 1 (no close yet), the second decrements /// to 0 and finally fires `WorkspaceTabClosed`. A `SaveFailed` /// removes the entry entirely (abort all close intents for that @@ -179,7 +179,7 @@ pub struct App { /// LIFO stack of recently-closed tab descriptors for Ctrl+Shift+T /// reopen. Capped at `CLOSED_TABS_CAPACITY`; the oldest entry is /// dropped when a new one is pushed against a full stack. Cleared - /// on disconnect — descriptors reference the active connection's + /// on disconnect: descriptors reference the active connection's /// tables, so reopening across connections would target the wrong /// schema. Editor descriptors round-trip the buffer text; /// dirty tabs lose their pending row/DDL edits because the @@ -237,7 +237,7 @@ pub struct StructureTabSlot { /// One workspace tab pinned to a single `(schema, table)` entity in /// Data (Browse) mode. The Structure (DDL) view is no longer fused -/// into the same tab — `WorkspaceTab::Structure` is its own +/// into the same tab: `WorkspaceTab::Structure` is its own /// dedicated tab opened via the sidebar right-click "Edit Structure" /// action. This split mirrors GNOME's "one TabPage per surface" /// idiom (Files, Builder) instead of a per-tab AdwViewSwitcher. @@ -255,7 +255,7 @@ pub struct TableTabSlot { /// view. Default for every sidebar single-click. /// - **Editor**: a free-form SQL workspace, orthogonal to any one /// table. -/// - **Structure**: the DDL editor — opens for "New Table" drafts +/// - **Structure**: the DDL editor, which opens for "New Table" drafts /// AND for "Edit Structure" against an existing table (via the /// sidebar right-click action). Replaces the previous inline /// AdwViewSwitcher on the Table tab. @@ -298,7 +298,7 @@ impl WorkspaceTab { pub enum OpenMode { /// Plain sidebar click: if a Browse tab for the table already /// exists, activate it; otherwise append a new Browse tab. - /// Never closes existing tabs — accumulates until the user dismisses. + /// Never closes existing tabs: accumulates until the user dismisses. SwitchOrAppend, /// Ctrl+click / right-click "Open in new tab": always append a /// new tab even when the same table is already open. @@ -307,7 +307,7 @@ pub enum OpenMode { // One Quark keyed `tp-workspace-tab-id` covers all tabs in the unified // workspace. We look up the WorkspaceTab from the HashMap to discover -// kind — qdata only carries identity. +// kind: qdata only carries identity. fn workspace_tab_id_quark() -> glib::Quark { static QUARK: std::sync::OnceLock = std::sync::OnceLock::new(); *QUARK.get_or_init(|| glib::Quark::from_str("tp-workspace-tab-id")) @@ -448,7 +448,7 @@ pub enum AppMsg { /// Tab right-click "Close Tabs to the Right" → close every tab /// after the targeted one in TabView display order. CloseWorkspaceTabsToRight(Uuid), - /// Drag-reorder / selection change / browse-tab-state-changed — + /// Drag-reorder / selection change / browse-tab-state-changed: /// triggers persistence (writes the current display order + each /// slot's state to workspace_state.json). WorkspaceTabsChanged, @@ -474,16 +474,16 @@ pub enum AppMsg { /// Routed before SaveFailedForTab so the tab can scroll-and-select /// the offending row before the error alert appears. FlashErrorRowForTab(Uuid, crate::services::change_tracker::StatementSource), - /// Ctrl+S — fire CommitSave on the active browse tab. No-op if + /// Ctrl+S: fire CommitSave on the active browse tab. No-op if /// the active tab is an Editor or there's no active connection. SaveActiveBrowseTab, /// Targeted variant: close-confirmation dialogs use this to commit /// a specific tab (which may not be the currently-active one when /// the user is closing a background tab via its X button). SaveActiveBrowseTabById(Uuid), - /// Ctrl+Z — undo the last pending change in the active tab. + /// Ctrl+Z: undo the last pending change in the active tab. UndoActiveBrowseTab, - /// Ctrl+Y — redo a previously undone change in the active tab. + /// Ctrl+Y: redo a previously undone change in the active tab. RedoActiveBrowseTab, /// Show a small alert dialog; used by BrowseTab for "select exactly /// one row" type messages. @@ -491,7 +491,7 @@ pub enum AppMsg { title: String, body: String, }, - /// Show a transient toast — used for inline-validation feedback like + /// Show a transient toast, used for inline-validation feedback like /// "Invalid date format" where a modal alert would be over-heavy. ShowToast(String), /// Tracker for a specific browse tab moved between empty / non-empty. @@ -520,7 +520,7 @@ pub enum AppMsg { schema: Option, table: String, }, - /// Async result of `ShowCreateTableForExisting` — the synthesised + /// Async result of `ShowCreateTableForExisting`: the synthesised /// CREATE statement is ready, open it in a new editor tab. ShowCreateTableLoaded { sql: String, @@ -532,7 +532,7 @@ pub enum AppMsg { schema: Option, table: String, }, - /// Confirmed drop — App runs DROP TABLE then closes any open + /// Confirmed drop: App runs DROP TABLE then closes any open /// Browse / Structure tabs for that table and refreshes sidebar. DropTableConfirmed { schema: Option, @@ -552,7 +552,7 @@ pub enum AppMsg { statements: Vec, }, /// Targeted save for a specific structure tab (close-with-pending - /// dialog uses this — mirrors `SaveActiveBrowseTabById`). Looks up + /// dialog uses this; mirrors `SaveActiveBrowseTabById`). Looks up /// the slot, materialises the tracker, dispatches /// `ExecuteStructureTransaction`. SaveActiveStructureTabById(Uuid), @@ -605,7 +605,7 @@ impl App { /// /// Single fallback site (was duplicated at 7 call sites). The /// tracing::warn! makes the latent bug visible if anything ever - /// asks for the driver id without an active connection — today + /// asks for the driver id without an active connection: today /// that would silently corrupt SQL quoting on non-Postgres drivers. pub(super) fn driver_id(&self) -> &str { match self.current_driver_id.as_deref() { @@ -695,7 +695,7 @@ impl SimpleComponent for App { // Sidebar wrapped in its own AdwToolbarView so it can // carry a sidebar-local AdwHeaderBar with a search - // toggle — same structure GNOME Files uses for its + // toggle, the same structure GNOME Files uses for its // Places sidebar. Window-decoration buttons live on // the outer (main) header bar already, so we hide // them here. @@ -764,7 +764,7 @@ impl SimpleComponent for App { set_use_markup: false, }, // Content is set imperatively at the end of - // init() — show_welcome_page swaps in the + // init(): show_welcome_page swaps in the // WelcomeView for the disconnected state, // and on_connected swaps in the workspace // tab strip on connect. The previously- @@ -829,7 +829,7 @@ impl SimpleComponent for App { // If a Save is mid-flight (async transaction running), block // the close until it resolves. Without this, the completion // handler would dispatch SaveCompleted to a tab that's - // already gone — the transaction commits in the background + // already gone: the transaction commits in the background // with no UI feedback. if !force_close_for_close.get() && in_flight_saves_for_close.get() > 0 { let dialog = adw::AlertDialog::new( @@ -857,13 +857,13 @@ impl SimpleComponent for App { return glib::Propagation::Stop; } // Already-confirmed close path (set by the dialog handler - // below) — skip the guard, save state, allow close. + // below): skip the guard, save state, allow close. // Browse + Structure tabs share the dirty-state guard: // either source of pending changes triggers the dialog. let has_pending = !dirty_tabs(&change_trackers_for_close, &structure_trackers_for_close).is_empty(); if !force_close_for_close.get() && has_pending { // Plural-form heading matches the per-tab dialog's - // tone — factual GNOME HIG language rather than the + // tone: factual GNOME HIG language rather than the // colloquial "throws them away" the body used to // carry. Per-tab dialog stays specific ("Save changes // to {name}"); window close groups across N tabs so @@ -904,7 +904,7 @@ impl SimpleComponent for App { } } force_close_for_resp.set(true); - // Re-fire close_request — guard sees the flag, + // Re-fire close_request: guard sees the flag, // saves window state, returns Proceed. window_for_resp.close(); } @@ -918,7 +918,7 @@ impl SimpleComponent for App { // the set drains. Any SaveFailed aborts. let browse_tabs = change_trackers_for_resp.dirty(); let structure_tabs = structure_trackers_for_resp.dirty(); - // Counter increments — a Table tab listed in both + // Counter increments: a Table tab listed in both // sets bumps to 2 so the window close waits for // BOTH the browse save and the structure save. { @@ -1064,7 +1064,7 @@ impl SimpleComponent for App { .set_key_capture_widget(Some(&widgets.sidebar_root)); // Empty-state placeholder. Shown by GtkListBox when no row is - // visible — covers both "the database has zero tables" and + // visible: covers both "the database has zero tables" and // "the search filtered everything out". Without this, the // sidebar renders as a blank surface and reads as broken. // AdwStatusPage `.compact` is the documented empty-state @@ -1100,7 +1100,7 @@ impl SimpleComponent for App { // per schema with a "+" button for "New Table…". Single- // schema connections (MySQL / SQLite) get one header // anchored to "main" / database-name with the same "+" - // affordance — the visual cue matters even when there's + // affordance: the visual cue matters even when there's // only one schema in the list. let multi_schema = total_distinct.len() >= 2; let idx = row.index(); @@ -1124,7 +1124,7 @@ impl SimpleComponent for App { .margin_bottom(6) .margin_start(12) // Match the row body's `margin_end: 12` so the "+" - // button sits flush with where row content ends — the + // button sits flush with where row content ends: the // previous 6px pulled it inward of the row label edge // and read as a misaligned column. .margin_end(12) @@ -1141,7 +1141,7 @@ impl SimpleComponent for App { // GtkPlacesSidebar section-header typography: small + bold // + ~55% alpha. `.heading` (libadwaita's "emphasized body") // combined with `.dim-label` rendered as bold-dim at body - // size — too loud for a section divider. `.caption-heading` + // size: too loud for a section divider. `.caption-heading` // is the small-bold variant the toolkit ships for exactly // this purpose. label.add_css_class("caption-heading"); @@ -1220,7 +1220,7 @@ impl SimpleComponent for App { .transition_type(gtk::StackTransitionType::Crossfade) .build(); // CTA button parented inside the empty-state status page so - // the "open editor" affordance is reachable with the mouse — + // the "open editor" affordance is reachable with the mouse: // without it the only path was the keyboard shortcut and the // tab-bar "+", and the tab bar is hidden in this empty state. let workspace_empty_cta = gtk::Button::builder() @@ -1439,7 +1439,7 @@ impl SimpleComponent for App { } AppMsg::UndoActiveBrowseTab => { // Ctrl+Z routes to BrowseTab undo only. Structure - // editing follows the snapshot+diff model — DDL undo + // editing follows the snapshot+diff model: DDL undo // is a session-level Discard, not a per-keystroke // history. Inside a Structure-mode Entry, the native // `gtk::Text` undo handles per-field text revert. diff --git a/linux/crates/app/src/ui/app/status_pages.rs b/linux/crates/app/src/ui/app/status_pages.rs index 488642135c..d1b1753494 100644 --- a/linux/crates/app/src/ui/app/status_pages.rs +++ b/linux/crates/app/src/ui/app/status_pages.rs @@ -8,14 +8,14 @@ use super::{App, AppMsg}; impl App { pub(super) fn show_welcome_page(&self, _sender: ComponentSender) { - // Welcome lives outside the ViewStack — it's the disconnected mode. + // Welcome lives outside the ViewStack: it's the disconnected mode. // The ViewSwitcherBar is hidden via on_disconnect so the welcome // view occupies the full toolbar surface. self.content_holder.set_content(Some(self.welcome_view.widget())); } /// Used during connect to convey "Connecting…". Persistent toast - /// (timeout 0) — held in `connect_progress_toast` until the connect + /// (timeout 0), held in `connect_progress_toast` until the connect /// resolves, at which point `dismiss_loading_page` clears it. Replaces /// the prior fire-and-forget toast which auto-dismissed at 2 s, well /// before remote / SSH-tunnelled connections resolve. diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index c054034975..4d92d0f60c 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -42,7 +42,7 @@ impl App { /// this `(schema, table)` it's just re-selected; otherwise a new /// `WorkspaceTab::Structure` is appended via /// `append_existing_structure_tab`. The Data-side Browse tab (if - /// any) is independent — both can stay open side by side. + /// any) is independent: both can stay open side by side. pub(super) fn on_edit_structure_tab( &mut self, schema: Option, @@ -144,10 +144,10 @@ impl App { /// Targeted save: commit a specific Structure tab's pending DDL /// without requiring it to be the active tab. Used by the - /// close-with-pending dialog's "Save" branch — the user might be + /// close-with-pending dialog's "Save" branch: the user might be /// closing a background tab via its X button. pub(super) fn save_structure_tab_by_id(&mut self, tab_id: Uuid, sender: ComponentSender) { - // If a save is already in flight for this tab, do nothing — + // If a save is already in flight for this tab, do nothing: // calling `on_execute_structure_transaction` would silently // early-return on the duplicate insert, never firing a // completion. The in-flight save's `on_structure_save_completed` @@ -184,7 +184,7 @@ impl App { } } - /// Drop succeeded on the driver — now close any open tabs for + /// Drop succeeded on the driver: now close any open tabs for /// the dropped table and refresh sidebar. Run synchronously on /// the GTK main thread so the close + sidebar refresh appear /// atomic to the user. @@ -320,8 +320,8 @@ impl App { } self.structure_saves_in_flight.borrow_mut().remove(&tab_id); - // Inspect slot kind first so we can branch — promote-and-close - // vs. in-place clear — without borrowing across mutation. + // Inspect slot kind first so we can branch (promote-and-close + // vs. in-place clear) without borrowing across mutation. enum SaveKind { PromoteNewToTable(Option, String), UpdateInPlace(Option, String), @@ -353,7 +353,7 @@ impl App { self.finish_close_workspace_tab(tab_id, &tab_view); } // After the CREATE TABLE lands, drop the user into a - // Browse tab on the freshly-created table — they just + // Browse tab on the freshly-created table: they just // designed it, so showing Data (empty grid) is the // natural next step. If they want to keep editing the // schema, sidebar right-click → "Edit Structure" @@ -511,7 +511,7 @@ impl App { let (page, schema, table_name, combined_dirty) = match slot { WorkspaceTab::Structure(s) => (&s.page, s.schema.as_deref(), s.table.clone(), dirty), WorkspaceTab::Table(s) => { - // Combine with the data-side dirty state — either + // Combine with the data-side dirty state: either // mode contributing pending changes prefixes the tab // with the "•" GNOME convention. let data = self diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index 8d3d8407e3..057580e64d 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -139,7 +139,7 @@ impl App { sort_col, sort_asc, } => { - // The persisted `mode` field is now ignored — Data + // The persisted `mode` field is now ignored: Data // and Structure are separate AdwTabPages. Restoring // a Table record always opens the Data side; if the // user had a Structure tab open, that's captured as @@ -322,7 +322,7 @@ impl App { let page = tab_view.append(&structure); let title = qualified_browse_tab_label(self.sidebar_schemas_distinct(), schema.as_deref(), &table); // Disambiguate from the Data-side tab with the same base name - // by suffixing the structure tab title — "products · Structure". + // by suffixing the structure tab title: "products · Structure". // GNOME uses U+00B7 middle dot as the canonical separator // (Files, Builder, Console all do this for compound titles). page.set_title(&format!("{title} · {}", crate::i18n::gettext("Structure"))); @@ -427,7 +427,7 @@ impl App { WorkspaceTab::Table(s) => s.page.clone(), }); let Some(page) = page else { return }; - // Tab title without the dirty bullet — the heading reads + // Tab title without the dirty bullet: the heading reads // as the natural-language name of the tab (`categories`), // not `• categories`. Strip a leading "• " if present. let raw_title = page.title().to_string(); @@ -560,7 +560,7 @@ impl App { return; }; // Snapshot the closed tab into the reopen stack BEFORE running - // the per-kind teardown below — the BrowseModel still has its + // the per-kind teardown below: the BrowseModel still has its // pagination + sort state intact, and the EditorTabSlot still // owns the latest query buffer. New-mode Structure drafts are // skipped because the table they describe doesn't exist yet, @@ -685,7 +685,7 @@ impl App { /// /// - `SwitchOrAppend` (plain click): if a Browse tab for /// `(schema, table)` is already open, activate it; otherwise - /// append a new Browse tab. Never closes anything — tabs only + /// append a new Browse tab. Never closes anything: tabs only /// go away when the user clicks the X. /// - `NewTab` (Ctrl+click / right-click "Open in new tab"): always /// append a new tab even if the same table is already open. @@ -866,7 +866,7 @@ fn do_persist_workspace_state( WorkspaceTabRecord::Table { schema: s.schema.clone(), table: s.table.clone(), - // `mode` is a legacy field — Table records always + // `mode` is a legacy field: Table records always // describe a Data-side Browse tab now. Structure // tabs persist as separate `Structure` records. mode: crate::services::workspace_state::PersistedTableMode::Data, @@ -897,7 +897,7 @@ impl App { /// Highlight the sidebar row matching the active Browse tab's /// `(schema, table)`. When the active tab is an Editor (or there - /// are no tabs), clear the sidebar selection — leaving a stale + /// are no tabs), clear the sidebar selection: leaving a stale /// row highlighted while the user is in the editor would imply /// the editor is showing that table's data, which it isn't. fn sync_sidebar_selection(&self) { @@ -936,7 +936,7 @@ impl App { let tabs = self.workspace_tabs.borrow(); let slot = tabs.get(&id)?; // Treat both legacy `Browse` and unified `Table` (regardless of - // active mode) as candidates — the Browse-side controller is + // active mode) as candidates: the Browse-side controller is // present in both. Save / Discard / Find paths route through // this helper. if slot.browse_tab().is_some() { Some(id) } else { None } @@ -975,7 +975,7 @@ impl App { /// /// Also flips `set_needs_attention` on the AdwTabPage so the tab /// bar's pulsing dot appears for background tabs that have unsaved - /// edits — matches AdwTabView's intended use and surfaces the + /// edits: matches AdwTabView's intended use and surfaces the /// dirty state when the user is in another tab. The flag is /// suppressed for the currently-selected tab since the user is /// actively looking at it (the "•" title prefix is enough cue). @@ -1007,7 +1007,7 @@ impl App { } pub(super) fn teardown_workspace_tabs(&mut self) { - // Synchronous flush — debouncer would skip the write since + // Synchronous flush: debouncer would skip the write since // teardown drops state before the 500ms timer would fire. self.do_persist_workspace_state_now(); self.cancel_all_editor_runs(); @@ -1102,7 +1102,7 @@ impl App { pub(super) fn on_replace_active_tab_query(&mut self, text: String, sender: ComponentSender) { // If an editor tab is active, replace its buffer in-place. If a // browse tab is active (or no tab at all), fall back to opening - // a new editor tab with the query — silent no-op was the prior + // a new editor tab with the query: silent no-op was the prior // (annoying) behaviour for users invoking from history while // browsing. if let Some(id) = self.selected_workspace_tab_id() @@ -1331,7 +1331,7 @@ fn describe_closed_tab(slot: &WorkspaceTab) -> Option { }), WorkspaceTab::Structure(s) => match s.mode { // New-Table drafts have no entity to point at and lose - // their in-progress DDL on close — nothing to reopen. + // their in-progress DDL on close: nothing to reopen. StructureMode::New => None, StructureMode::Edit => Some(ClosedTabDescriptor::Structure { schema: s.schema.clone(), @@ -1359,7 +1359,7 @@ impl App { } let descriptor = self.closed_tabs_stack.borrow_mut().pop_back(); let Some(descriptor) = descriptor else { - // Stack empty — nothing to reopen. Toast keeps the + // Stack empty: nothing to reopen. Toast keeps the // shortcut from feeling broken when the user hits it // before having closed anything (or after a reconnect // wiped the stack). @@ -1392,7 +1392,7 @@ impl App { } /// Returns a tooltip for an Editor tab. Empty for blank queries; for -/// non-empty queries, a 200-char preview — but only when distinct from +/// non-empty queries, a 200-char preview, but only when distinct from /// the (truncated) label, so non-truncated labels don't get a redundant /// hover popup. fn editor_tab_tooltip(query: &str, label: &str) -> Option { @@ -1418,8 +1418,6 @@ fn editor_tab_tooltip(query: &str, label: &str) -> Option { if preview == label { None } else { Some(preview) } } -/// Forward an editor tab's signals into the window. The toast overlay -/// is the window's, so a replaced query's Undo button lives there. /// The structure tab's signals, as this window's inputs for the tab. fn connect_structure(structure: &StructureTab, tab_id: Uuid, input: &relm4::Sender) { let dirty = input.clone(); @@ -1478,6 +1476,8 @@ fn connect_browse(browse: &BrowseTab, tab_id: Uuid, input: &relm4::Sender &'static [&'static str] { match driver_id { @@ -119,7 +119,7 @@ pub(super) fn default_type_for(driver_id: &str) -> String { } /// Render a draft column's summary line for the collapsed expander -/// header — `varchar(255) · NOT NULL · Primary key`. Order: type, +/// header: `varchar(255) · NOT NULL · Primary key`. Order: type, /// nullability, primary key, auto-increment. Empty parts are skipped /// so a freshly-added column with default state shows the minimum /// useful information. @@ -147,7 +147,7 @@ fn format_column_subtitle(col: &DraftColumn) -> String { /// reveals one `AdwEntryRow` / `AdwSwitchRow` per editable attribute. /// SQLite-restricted fields render as `set_sensitive(false)` with /// explanatory tooltips so the user understands why they can't edit. -/// Non-original (newly-added) columns always allow full editing — +/// Non-original (newly-added) columns always allow full editing: /// those become `AddColumn` ops which SQLite accepts at execution. /// /// `suppress_emit` lets the caller mark a window during which signal @@ -172,7 +172,7 @@ pub(super) fn build_column_expander_row( .build(); row.set_widget_name(&format!("col-row-{index}")); - // Trash button as a header-suffix on the expander row itself — + // Trash button as a header-suffix on the expander row itself, which // remains visible whether the row is expanded or collapsed. let remove_button = gtk::Button::builder() .icon_name(crate::ui::icons::USER_TRASH) @@ -211,7 +211,7 @@ pub(super) fn build_column_expander_row( }); row.add_row(&name_row); - // Type (AdwEntryRow — free text). A suffix MenuButton offers the + // Type (AdwEntryRow, free text). A suffix MenuButton offers the // curated `driver_types()` suggestions; free-text input remains the // primary path so custom types like `decimal(10,2)` or Postgres // `enum` literals work without enumeration. @@ -363,7 +363,7 @@ pub(super) fn build_column_expander_row( /// Build a suffix MenuButton for the type AdwEntryRow that opens a /// native `gtk::PopoverMenu` listing curated `driver_types()` for /// `driver_id`. Selecting an entry rewrites the target row's text -/// (which fires the row's `changed` signal — the existing handler +/// (which fires the row's `changed` signal; the existing handler /// picks up the new value). Free-text input via the entry stays as /// the primary path. /// @@ -371,7 +371,7 @@ pub(super) fn build_column_expander_row( /// causes GTK to render a `gtk::PopoverMenu` automatically. That is /// the same widget powering app menus, right-click menus, and /// gnome-menus across the desktop, so the rendering is identical to -/// every other GNOME menu the user has ever seen — proper menu-item +/// every other GNOME menu the user has ever seen: proper menu-item /// padding, hover/active states, separators, focus ring, all native. /// /// Each type-name menu item activates a single SimpleAction diff --git a/linux/crates/app/src/ui/structure_tab/indexes.rs b/linux/crates/app/src/ui/structure_tab/indexes.rs index e6b7c47252..855cf5cef1 100644 --- a/linux/crates/app/src/ui/structure_tab/indexes.rs +++ b/linux/crates/app/src/ui/structure_tab/indexes.rs @@ -10,7 +10,7 @@ use tablepro_core::meta::IndexKey; use super::{StructureTabInput, StructureTabSender}; -/// Tiny inline pill rendered as an AdwActionRow suffix — used by the +/// Tiny inline pill rendered as an AdwActionRow suffix, used by the /// indexes list to show UNIQUE / PRIMARY tags. `accent_class` is the /// CSS class controlling the colour (`dim-label`, `accent`, etc.). fn index_badge(label: &str, accent_class: &str) -> gtk::Label { @@ -20,7 +20,7 @@ fn index_badge(label: &str, accent_class: &str) -> gtk::Label { badge } -/// Index row as a native `AdwActionRow` — title is the index name, +/// Index row as a native `AdwActionRow`: title is the index name, /// subtitle is the comma-separated column list. UNIQUE / PRIMARY are /// small caption suffixes, the trash button is an end-suffix. The row /// participates in `boxed-list` styling for free; no manual margins. @@ -44,7 +44,7 @@ pub(super) fn build_index_row(index: usize, idx: &IndexInfo, sender: StructureTa .valign(gtk::Align::Center) .build(); remove_button.add_css_class("flat"); - // Primary index isn't user-droppable — it's owned by the PK + // Primary index isn't user-droppable: it's owned by the PK // column constraint and removing it breaks the table. if idx.primary { remove_button.set_sensitive(false); diff --git a/linux/crates/app/src/ui/structure_tab_dialogs.rs b/linux/crates/app/src/ui/structure_tab_dialogs.rs index 5699fe5ac0..08c5fc2f1b 100644 --- a/linux/crates/app/src/ui/structure_tab_dialogs.rs +++ b/linux/crates/app/src/ui/structure_tab_dialogs.rs @@ -8,7 +8,7 @@ //! entry forms belong on AdwDialog with explicit headerbar buttons. //! //! Form fields use AdwEntryRow / AdwSwitchRow / AdwComboRow inside an -//! AdwPreferencesGroup — the same pattern GNOME Settings uses for +//! AdwPreferencesGroup, the same pattern GNOME Settings uses for //! every "Add account / Add network / Add printer" dialog. Raw //! GtkEntry + sibling labels was the quick-MVP layout but it doesn't //! pick up the rounded-corner / row-separator styling and breaks @@ -93,7 +93,7 @@ fn build_form_dialog(title: &str, submit_label: &str) -> (adw::Dialog, gtk::Box, (dialog, content, submit_btn) } -/// Build a section header label sized as a small caption — used +/// Build a section header label sized as a small caption, used /// above sub-groupings inside form dialogs. AdwPreferencesGroup /// already handles its own header, so this is for the "Columns" /// label that sits above the column checklist (which is itself a @@ -104,8 +104,8 @@ fn section_label(title: &str) -> gtk::Label { label } -/// Build a boxed-list `ListBox` of `AdwActionRow` + `CheckButton` — -/// one per draft column — for picking columns inside a form dialog. +/// Build a boxed-list `ListBox` of `AdwActionRow` + `CheckButton`, +/// one per draft column, for picking columns inside a form dialog. /// Returns the list (so the caller can `body.append` it) plus the /// shared `Rc` of name/check pairs so the submit handler can extract /// which columns the user ticked. @@ -205,7 +205,7 @@ pub(super) fn present_fk_dialog( let (dialog, body, submit_btn) = build_form_dialog(&crate::i18n::gettext("Add Foreign Key"), &crate::i18n::gettext("Add")); - // Name in its own AdwPreferencesGroup at the top — matches the + // Name in its own AdwPreferencesGroup at the top, which matches the // shape of the column-edit drawer + every other GNOME form. let name_group = adw::PreferencesGroup::builder().build(); let name_row = adw::EntryRow::builder() @@ -220,7 +220,7 @@ pub(super) fn present_fk_dialog( body.append(&columns_list); // Reference target + ON DELETE / ON UPDATE in a second group. - // Reference columns stay free-text — wiring up an async fetch of + // Reference columns stay free-text: wiring up an async fetch of // the referenced table's columns is out of scope for an MVP. let ref_group = adw::PreferencesGroup::builder() .title(crate::i18n::gettext("References")) @@ -288,7 +288,7 @@ pub(super) fn present_fk_dialog( ))); return; } - // Source and reference column counts must match — `(a, b) → (x)` + // Source and reference column counts must match: `(a, b) → (x)` // is structurally invalid SQL. Drivers reject it, but with an // opaque error after Save instead of an inline guard. if ref_cols.len() != cols.len() { From 4a69ffe8fc78c9ed74135b7503ac9369a12ca963 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 20:14:31 +0700 Subject: [PATCH 096/178] fix(linux): drop a released session's queued sidebar and health updates --- linux/CHANGELOG.md | 1 + linux/crates/app/src/services/from_session.rs | 60 +++++++++ linux/crates/app/src/services/mod.rs | 1 + linux/crates/app/src/ui/app/connection.rs | 118 +++++++++++++----- linux/crates/app/src/ui/app/mod.rs | 17 ++- linux/po/POTFILES.in | 1 + 6 files changed, 163 insertions(+), 35 deletions(-) create mode 100644 linux/crates/app/src/services/from_session.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 3d59ce5e9e..3d2697e339 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -46,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refresh did not show a column added to the table since the tab opened. - A table tab kept the old header of a column renamed in the Structure tab. - A refresh that failed replaced the rows in a table tab with an error page. +- Switching connections could show the previous connection's tables in the sidebar, or its reconnect banner. ### Security diff --git a/linux/crates/app/src/services/from_session.rs b/linux/crates/app/src/services/from_session.rs new file mode 100644 index 0000000000..79d775d576 --- /dev/null +++ b/linux/crates/app/src/services/from_session.rs @@ -0,0 +1,60 @@ +use std::sync::{Arc, Weak}; + +use tablepro_session::Session; + +use super::active_session::ActiveSession; + +/// A message a session's watcher queued for the window, and the session +/// it came from. +/// +/// The window's queue can hold one behind the switch to another +/// session, and by the time it is handled it describes a session the +/// window has let go of. +#[derive(Debug)] +pub struct FromSession { + origin: Weak, + value: T, +} + +impl FromSession { + pub fn new(origin: Weak, value: T) -> Self { + Self { origin, value } + } + + /// The value, while the session it came from is still the window's. + pub fn if_current(self, active: &ActiveSession) -> Option { + let current = active.get()?; + Weak::ptr_eq(&Arc::downgrade(¤t), &self.origin).then_some(self.value) + } +} + +#[cfg(test)] +mod tests { + use tablepro_test_support::ConnectionScript; + + use super::*; + use crate::test_support::FakeSession; + + #[test] + fn only_the_current_session_is_heard() { + let runtime = crate::test_support::test_runtime(); + let a = FakeSession::start(ConnectionScript::default(), [], runtime.tasks()); + let b = FakeSession::start(ConnectionScript::default(), [], runtime.tasks()); + let active = ActiveSession::new(); + let from_a = || FromSession::new(Arc::downgrade(&a.session), "a"); + + assert_eq!(from_a().if_current(&active), None, "heard with no session open"); + active.replace(a.session.clone()); + assert_eq!(from_a().if_current(&active), Some("a")); + active.replace(b.session.clone()); + assert_eq!( + from_a().if_current(&active), + None, + "heard after the switch to another session" + ); + assert_eq!( + FromSession::new(Arc::downgrade(&b.session), "b").if_current(&active), + Some("b") + ); + } +} diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index 0379f9b149..2e0b608607 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -6,6 +6,7 @@ pub mod active_session; pub mod cell_overlay; pub mod change_tracker; pub mod change_tracker_registry; +pub mod from_session; pub mod history_availability; pub mod history_service; pub mod secret_labels; diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index 9f98e2f537..454b4b8437 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -18,6 +18,7 @@ use tablepro_session::RequestTarget; use tablepro_session::connect::StoreConnectionWrites; use crate::services::active_session::ActiveSession; +use crate::services::from_session::FromSession; use crate::ui::connect_dialog::{ConnectDialog, ConnectDialogInit, ConnectPrefill, ConnectSubmission, FormField}; use crate::ui::error_text::TlsRemedy; use crate::ui::file_read::{FileReadError, read_capped_file}; @@ -91,17 +92,18 @@ fn spawn_local(future: futures::future::LocalBoxFuture<'static, ()>) { /// sidebar's scope. The list on screen when the watch starts is not /// delivered again, and a refresh that failed keeps the list it had. pub(super) fn catalog_watch( - session: &Session, - mut deliver: impl FnMut(ObjectListSnapshot) + 'static, + session: &Arc, + mut deliver: impl FnMut(FromSession) + 'static, ) -> WatchForwarder { let objects = session.subscribe_objects(ObjectScope::DefaultSchema); + let origin = Arc::downgrade(session); let mut shown = objects.borrow().generation; WatchForwarder::new( objects, move |snapshot: ObjectListSnapshot| { if snapshot.generation != shown { shown = snapshot.generation; - deliver(snapshot); + deliver(FromSession::new(origin.clone(), snapshot)); } else if let Some(error) = &snapshot.last_error { tracing::warn!(%error, "the table list could not be read again"); } @@ -296,17 +298,20 @@ impl App { let first = session.subscribe_objects(ObjectScope::DefaultSchema).borrow().clone(); let health_sender = sender.clone(); + let health_origin = Arc::downgrade(&session); let catalog_sender = sender.clone(); self.session_links = Some(SessionLinks { key, _claim: claim, _health: WatchForwarder::new( session.health(), - move |health| health_sender.input(AppMsg::HealthChanged(health)), + move |health| { + health_sender.input(AppMsg::HealthChanged(FromSession::new(health_origin.clone(), health))); + }, spawn_local, ), - _catalog_watch: catalog_watch(&session, move |snapshot| { - catalog_sender.input(AppMsg::CatalogChanged(snapshot)); + _catalog_watch: catalog_watch(&session, move |update| { + catalog_sender.input(AppMsg::CatalogChanged(update)); }), }); for warning in warnings { @@ -1171,16 +1176,16 @@ fn failed_banner_text(cause: tablepro_session::FailureCause) -> String { #[cfg(test)] mod tests { - use std::time::Duration; - use std::cell::RefCell; + use std::collections::VecDeque; use std::rc::Rc; + use std::time::Duration; + use tablepro_core::LivenessPolicy; use tablepro_core::meta::ObjectKind; use tablepro_session::fakes::{FakeConnector, FakeCredentialSource}; use tablepro_session::operation::DefaultDeadlines; use tablepro_session::runtime::CountingHold; - use tablepro_session::{FailureCause, SessionDeps}; use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; @@ -1272,6 +1277,38 @@ mod tests { snapshot.objects.iter().map(|object| object.name.clone()).collect() } + /// The window's input queue, holding what the catalog watch posted + /// until the window handles it. + type Queue = Rc>>>; + + fn post_to(queue: &Queue) -> impl FnMut(FromSession) + 'static { + let queue = queue.clone(); + move |update| queue.borrow_mut().push_back(update) + } + + /// Handle the queued lists the way the window's `CatalogChanged` does. + fn handle(queue: &Queue, active: &ActiveSession, sidebar: &mut Vec) { + let queued: Vec<_> = queue.borrow_mut().drain(..).collect(); + for update in queued { + if let Some(snapshot) = update.if_current(active) { + *sidebar = names(&snapshot); + } + } + } + + fn refresh_objects( + session: &Arc, + runtime: &crate::runtime::AppRuntime, + ) -> impl Future + use<> { + let session = session.clone(); + let refreshing = runtime + .tasks() + .spawn_task(async move { session.refresh_objects(ObjectScope::DefaultSchema).await }); + async move { + let _ = refreshing.await; + } + } + #[gtk4::test] fn late_catalog_commit_after_disconnect_leaves_sidebar_untouched() { let runtime = crate::test_support::test_runtime(); @@ -1286,18 +1323,12 @@ mod tests { .list_objects .always(FakeReply::ok(vec![object("from_b", ObjectKind::Table)])); let b = FakeSession::start(second, [], runtime.tasks()); - let sidebar: Rc>> = Rc::default(); - let show = |sidebar: &Rc>>| { - let sidebar = sidebar.clone(); - move |snapshot: ObjectListSnapshot| *sidebar.borrow_mut() = names(&snapshot) - }; - let watching_a = catalog_watch(&a.session, show(&sidebar)); - let late = { - let session = a.session.clone(); - runtime - .tasks() - .spawn_task(async move { session.refresh_objects(ObjectScope::DefaultSchema).await }) - }; + let active = ActiveSession::new(); + active.replace(a.session.clone()); + let queue = Queue::default(); + let mut sidebar = Vec::new(); + let watching_a = catalog_watch(&a.session, post_to(&queue)); + let late = refresh_objects(&a.session, &runtime); let counters = a.connection.counters(); crate::test_support::wait_until(Duration::from_secs(5), || { CallCounters::get(&counters.list_objects) == 1 @@ -1307,26 +1338,51 @@ mod tests { // Disconnect A and adopt B, in the order the window does. drop(watching_a); drop(a.session.begin_shutdown()); - let _watching_b = catalog_watch(&b.session, show(&sidebar)); - let refreshed_b = { - let session = b.session.clone(); - runtime - .tasks() - .spawn_task(async move { session.refresh_objects(ObjectScope::DefaultSchema).await }) - }; + active.replace(b.session.clone()); + let _watching_b = catalog_watch(&b.session, post_to(&queue)); + let refreshed_b = refresh_objects(&b.session, &runtime); crate::test_support::finish_on_main(Duration::from_secs(5), async move { - let _ = refreshed_b.await; - let _ = late.await; + refreshed_b.await; + late.await; }) .expect("both lists to land"); crate::test_support::drain_main_context(); + handle(&queue, &active, &mut sidebar); assert_eq!( names(&a.session.subscribe_objects(ObjectScope::DefaultSchema).borrow()), ["from_a"], "A's late list never landed, so the test proved nothing" ); - assert_eq!(*sidebar.borrow(), ["from_b"]); + assert_eq!(sidebar, ["from_b"]); + } + + #[gtk4::test] + fn a_list_queued_before_a_switch_is_not_shown() { + let runtime = crate::test_support::test_runtime(); + let mut first = ConnectionScript::default(); + first + .list_objects + .always(FakeReply::ok(vec![object("from_a", ObjectKind::Table)])); + let a = FakeSession::start(first, [], runtime.tasks()); + let b = FakeSession::start(ConnectionScript::default(), [], runtime.tasks()); + let active = ActiveSession::new(); + active.replace(a.session.clone()); + let queue = Queue::default(); + let mut sidebar = vec!["from_b".to_owned()]; + let watching_a = catalog_watch(&a.session, post_to(&queue)); + crate::test_support::finish_on_main(Duration::from_secs(5), refresh_objects(&a.session, &runtime)) + .expect("A's list to land"); + crate::test_support::wait_until(Duration::from_secs(5), || queue.borrow().len() == 1) + .expect("A's list was posted to the window"); + + // B's SessionReady was ahead of A's list in the queue. + drop(watching_a); + drop(a.session.begin_shutdown()); + active.replace(b.session.clone()); + handle(&queue, &active, &mut sidebar); + + assert_eq!(sidebar, ["from_b"], "A's list replaced B's sidebar"); } fn object(name: &str, kind: ObjectKind) -> ObjectInfo { diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 9edb241d7f..ceb4e1109c 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -27,6 +27,7 @@ use super::history_dialog::HistoryDialog; use super::sidebar_row::{SidebarRow, SidebarRowOutput}; use super::welcome_view::{WelcomeView, WelcomeViewInit, WelcomeViewOutput}; use crate::services::change_tracker_registry::ChangeTrackerRegistry; +use crate::services::from_session::FromSession; use crate::services::session_context::SessionContext; use crate::services::structure_tracker_registry::StructureTrackerRegistry; @@ -347,10 +348,10 @@ pub enum AppMsg { /// The banner's Retry: ask the monitor to reconnect now. RetryConnection, /// The adopted session's health moved. - HealthChanged(SessionHealth), + HealthChanged(FromSession), /// The session's object list for the sidebar changed: a reconnect /// read it again, or a schema change refreshed it. - CatalogChanged(ObjectListSnapshot), + CatalogChanged(FromSession), DialogClosed, SelectTable { schema: Option, @@ -1368,9 +1369,17 @@ impl SimpleComponent for App { } => self.on_connect_failed(generation, message, edit, sender), AppMsg::CancelConnect => self.on_cancel_connect(), AppMsg::EditConnection(retry) => self.on_edit_connection(*retry, sender), - AppMsg::HealthChanged(health) => self.refresh_health_banner(&health), + AppMsg::HealthChanged(update) => { + if let Some(health) = update.if_current(&self.context.active) { + self.refresh_health_banner(&health); + } + } AppMsg::RetryConnection => self.on_retry_connection(), - AppMsg::CatalogChanged(snapshot) => self.on_catalog_changed(&snapshot), + AppMsg::CatalogChanged(update) => { + if let Some(snapshot) = update.if_current(&self.context.active) { + self.on_catalog_changed(&snapshot); + } + } AppMsg::Disconnect => self.on_disconnect(sender), AppMsg::ForceDisconnect => self.do_disconnect(sender), AppMsg::DialogClosed => self.dialog = None, diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 4c6a8f41ab..11581bdbfc 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -22,6 +22,7 @@ crates/app/src/services/active_session.rs crates/app/src/services/cell_overlay.rs crates/app/src/services/change_tracker.rs crates/app/src/services/change_tracker_registry.rs +crates/app/src/services/from_session.rs crates/app/src/services/history_availability.rs crates/app/src/services/history_service.rs crates/app/src/services/mod.rs From 9f2d81672bb325697e83eb76a94cd63aaeb2e42f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 22:17:43 +0700 Subject: [PATCH 097/178] fix(linux): show the error page when a tab's filter no longer fits --- linux/crates/app/src/ui/browse_tab/fetch.rs | 17 +++- linux/crates/app/src/ui/browse_tab/tests.rs | 87 +++++++++++++++++++-- 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/fetch.rs b/linux/crates/app/src/ui/browse_tab/fetch.rs index 61dd4bbb40..a07bbac228 100644 --- a/linux/crates/app/src/ui/browse_tab/fetch.rs +++ b/linux/crates/app/src/ui/browse_tab/fetch.rs @@ -91,10 +91,8 @@ impl BrowseModel { let Some(plan) = self.read_plan() else { return; }; - // A filter the dialect cannot write is the user's to fix, so it - // is a toast now rather than a failed page later. if let Some(message) = self.filter_problem() { - fx.output(BrowseTabOutput::ShowToast(message)); + self.on_filter_problem(&message, fx); return; } let ticket = self.issue(BrowseRequestKind::Page); @@ -215,6 +213,19 @@ impl BrowseModel { .map(|error| error.to_string()) } + /// A filter the dialect cannot write is the user's to fix, and no + /// page is read under it. A page already read under these columns + /// stays with a toast; without one, the error page says why rather + /// than a spinner that nothing will replace. + fn on_filter_problem(&mut self, message: &str, fx: &mut Effects) { + if self.current_result.is_some() { + self.refresh_inner_stack_visibility(); + fx.output(BrowseTabOutput::ShowToast(message.to_owned())); + return; + } + self.show_error(message); + } + pub(super) fn on_command(&mut self, command: BrowseCommand, fx: &mut Effects) { match command { BrowseCommand::Columns(Stamped { stamp, value }) => { diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index f5388435ba..ebff2b1e99 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -347,12 +347,21 @@ struct Harness { impl Harness { fn open(script: ConnectionScript, reconnects: impl IntoIterator) -> Self { + Self::open_filtered(script, reconnects, FilterSet::default()) + } + + /// The tab opens under `saved`, the filter the table was left with. + fn open_filtered( + script: ConnectionScript, + reconnects: impl IntoIterator, + saved: FilterSet, + ) -> Self { libadwaita::init().expect("libadwaita initialises under the test backend"); crate::register_test_resources(); let runtime = crate::test_support::test_runtime(); let fake = FakeSession::start(script, reconnects, runtime.tasks()); let root = tempfile::tempdir().expect("tempdir"); - let tab = users_tab(&fake.session, &runtime, &root); + let tab = filtered_users_tab(&fake.session, &runtime, &root, saved); Self { tab: Some(tab), others: Vec::new(), @@ -389,18 +398,30 @@ impl Harness { } fn users_tab(session: &Arc, runtime: &crate::runtime::AppRuntime, root: &tempfile::TempDir) -> BrowseTab { + filtered_users_tab(session, runtime, root, FilterSet::default()) +} + +fn filtered_users_tab( + session: &Arc, + runtime: &crate::runtime::AppRuntime, + root: &tempfile::TempDir, + saved: FilterSet, +) -> BrowseTab { let tasks = runtime.tasks(); + let connection_id = uuid::Uuid::new_v4(); + let filter_settings = crate::persistence::FilterSettingsStore::load(root.path().join("filters.json"), &tasks); + filter_settings.set_filters(connection_id, None, "users", saved); BrowseTab::new(BrowseTabInit { tracker: crate::services::tab_tracker_handle::TabTrackerHandle::default(), schema: None, table: "users".to_owned(), - connection_id: Some(uuid::Uuid::new_v4()), + connection_id: Some(connection_id), read_only: false, page_size: 100, initial_offset: 0, initial_sort: None, column_widths: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), &tasks), - filter_settings: crate::persistence::FilterSettingsStore::load(root.path().join("filters.json"), &tasks), + filter_settings, session: session.clone(), tasks, }) @@ -428,10 +449,10 @@ fn text(values: &[&str]) -> Vec { values.iter().map(|value| Value::Text((*value).to_owned())).collect() } -fn not_null_name() -> FilterSet { +fn not_null(column: &str) -> FilterSet { FilterSet { rules: vec![FilterRule { - column: "name".to_owned(), + column: column.to_owned(), op: FilterOp::IsNotNull, value: None, }], @@ -439,6 +460,10 @@ fn not_null_name() -> FilterSet { } } +fn not_null_name() -> FilterSet { + not_null("name") +} + #[gtk4::test] fn stale_page_reply_ignored() { let mut script = serving_table(); @@ -661,6 +686,58 @@ fn a_failed_page_under_a_new_filter_shows_the_error() { assert!(harness.shown().is_empty()); } +#[gtk4::test] +fn a_saved_filter_on_a_dropped_column_shows_the_error() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["unfiltered"]))); + let harness = Harness::open_filtered(script, [], not_null("nickname")); + let counters = harness.connection.counters(); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the tab stayed on its spinner"); + + assert_eq!(CallCounters::get(&counters.query_bound), 0); + assert_eq!(CallCounters::get(&counters.count_rows), 0); +} + +#[gtk4::test] +fn a_filter_column_renamed_away_drops_the_grid_for_the_error() { + let renamed = [column("id", true), column("full_name", false)]; + let mut script = serving_table(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::ok(renamed.to_vec())); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open_filtered(script, [], not_null_name()); + wait_until(WAIT, || harness.shown() == text(&["Ada"])).expect("the first page"); + + harness + .session + .invalidate_table(&tablepro_core::TableRef::unqualified("users")); + harness.tab().send(BrowseTabInput::SchemaChanged); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")) + .expect("the grid read under the old columns stayed on screen"); + assert!(harness.shown().is_empty()); + assert_eq!(harness.tab().columns(), renamed); +} + +#[gtk4::test] +fn a_refresh_under_a_filter_that_no_longer_fits_keeps_the_rows() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["kept"]))); + let harness = Harness::open_filtered(script, [], not_null_name()); + wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); + let toasts = toasts(harness.tab()); + + harness.tab().send(BrowseTabInput::FilterApplied(not_null("nickname"))); + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || toasts.borrow().len() == 2).expect("the filter that does not fit was not reported"); + + assert_eq!(harness.shown(), text(&["kept"])); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); +} + #[gtk4::test] fn export_and_save_reach_the_window_as_signals() { let mut script = serving_table(); From 72cc070547a0cc665cf15ec331201c1c8ea217bb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 22:18:09 +0700 Subject: [PATCH 098/178] fix(linux): keep a structure tab's definition when a re-read fails --- linux/crates/app/src/ui/structure_tab/mod.rs | 22 ++++++++-- .../crates/app/src/ui/structure_tab/tests.rs | 44 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index 344cfb5a89..149f1052a0 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -199,6 +199,7 @@ impl StructureTab { next_column_seq: Rc::new(RefCell::new(1)), column_popovers: Rc::new(RefCell::new(Vec::new())), refetching: Rc::new(Cell::new(false)), + definition_shown: false, _theme_handler: theme_handler, }; imp.model.replace(Some(model)); @@ -435,6 +436,9 @@ pub(crate) struct StructureModel { /// still hold the pre-save state, so diffing them would surface /// phantom pending ops; `recompute_dirty_state` waits it out. refetching: Rc>, + /// The editor shows a definition that `original_*` still match, so + /// a read of it that fails can leave it on screen. + definition_shown: bool, /// The SQL preview follows the desktop theme through the app-wide /// style manager, which would keep the handler past the tab. _theme_handler: HandlerGuard, @@ -733,6 +737,7 @@ impl StructureModel { *self.foreign_keys.borrow_mut() = foreign_keys.clone(); *self.original_fks.borrow_mut() = foreign_keys; self.inner_stack.set_visible_child_name("editor"); + self.definition_shown = true; // The snapshots are current again, so the diff counts; it is // empty now, which also clears anything typed during the read. self.refetching.set(false); @@ -740,7 +745,15 @@ impl StructureModel { fx.input(StructureTabInput::Refresh); } - fn on_load_failed(&self, message: &str) { + fn on_load_failed(&mut self, message: &str, fx: &mut StructureEffects) { + if self.definition_shown { + self.inner_stack.set_visible_child_name("editor"); + fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext_f( + "Couldn't refresh the structure: {error}", + &[("error", message)], + ))); + return; + } // Inline on the status page: a modal alert would say the same // and make the user dismiss it before seeing the page. self.error_status.set_description(Some(message)); @@ -759,7 +772,7 @@ impl StructureModel { match value { Ok(loaded) => self.on_loaded(loaded, fx), Err(error) => { - self.on_load_failed(&crate::ui::error_text::operation_error_message(&error)); + self.on_load_failed(&crate::ui::error_text::operation_error_message(&error), fx); let issued = self.issued_on.get(&StructureRequestKind::Definition).copied(); if issued.is_some_and(|issued| self.session.link_generation() > issued) { self.retry_failed(); @@ -784,7 +797,9 @@ impl StructureModel { fn retry_failed(&mut self) { if self.failed.take().contains(&StructureRequestKind::Definition) { - self.show_loading(); + if !self.definition_shown { + self.show_loading(); + } self.fetch_definition(); } } @@ -1028,6 +1043,7 @@ impl StructureModel { } drop(last); self.refetching.set(true); + self.definition_shown = false; self.fetch_definition(); } } diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index 9e35966c1e..c80bb43d92 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -257,3 +257,47 @@ fn drop_asks_the_window_with_the_table_it_shows() { assert_eq!(*asked.borrow(), [(None, "users".to_owned())]); } + +fn toasts(tab: &StructureTab) -> std::rc::Rc>> { + let toasts = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = toasts.clone(); + tab.connect_toast(move |_, text| sink.borrow_mut().push(text)); + toasts +} + +/// The table reads once, then every read after it fails. +fn readable_once() -> ConnectionScript { + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .once(FakeReply::ok(definition("id"))) + .always(FakeReply::err(DriverError::server("denied"))); + script +} + +#[gtk4::test] +fn a_failed_read_after_a_schema_change_keeps_the_definition() { + let harness = Harness::open(StructureMode::Edit, readable_once(), []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let toasts = toasts(harness.tab()); + + harness.tab().send(StructureTabInput::SchemaChanged); + wait_until(WAIT, || !toasts.borrow().is_empty()).expect("the failed read was not reported"); + + assert_eq!(harness.page().as_deref(), Some("editor")); + assert_eq!(harness.column_names(), ["id"]); + assert!(toasts.borrow()[0].contains("denied"), "{:?}", toasts.borrow()); +} + +#[gtk4::test] +fn a_failed_read_after_a_save_shows_the_error() { + let harness = Harness::open(StructureMode::Edit, readable_once(), []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + + harness + .tab() + .send(StructureTabInput::SaveCompleted { new_table_name: None }); + + wait_until(WAIT, || harness.page().as_deref() == Some("error")) + .expect("a definition from before the save stayed editable"); +} From ec74c4a74ecca7a7e96d2efc341540d9c4f206b8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 22:18:30 +0700 Subject: [PATCH 099/178] fix(linux): ignore a table drop that lands after a connection switch Show CREATE TABLE's reply gets the same session check. --- linux/CHANGELOG.md | 1 + linux/crates/app/src/ui/app/mod.rs | 31 ++++++++++++------------ linux/crates/app/src/ui/app/structure.rs | 27 +++++++++------------ 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 3d2697e339..71419fd54d 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -47,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A table tab kept the old header of a column renamed in the Structure tab. - A refresh that failed replaced the rows in a table tab with an error page. - Switching connections could show the previous connection's tables in the sidebar, or its reconnect banner. +- Switching connections during a table drop could close the new connection's tabs on a table of the same name. ### Security diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index ceb4e1109c..81184a829b 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -521,11 +521,9 @@ pub enum AppMsg { schema: Option, table: String, }, - /// Async result of `ShowCreateTableForExisting`: the synthesised - /// CREATE statement is ready, open it in a new editor tab. - ShowCreateTableLoaded { - sql: String, - }, + /// The CREATE statement `ShowCreateTableForExisting` built, for a + /// new editor tab. + ShowCreateTableLoaded(FromSession), /// Sidebar right-click → "Drop Table…", or in-tab Drop button. /// App shows the AdwAlertDialog confirmation; on confirm dispatches /// `DropTableConfirmed`. @@ -533,18 +531,13 @@ pub enum AppMsg { schema: Option, table: String, }, - /// Confirmed drop: App runs DROP TABLE then closes any open - /// Browse / Structure tabs for that table and refreshes sidebar. + /// The user confirmed the drop. DropTableConfirmed { schema: Option, table: String, }, - /// DROP TABLE returned Ok from the driver. Now-safe to close - /// matching tabs + refresh sidebar. - DropTableSucceeded { - schema: Option, - table: String, - }, + /// The table is gone, so its tabs close without asking. + DropTableSucceeded(FromSession), /// Structure tab Save: run the materialised DDL statements /// sequentially. Postgres wraps in BEGIN / COMMIT for atomicity; /// MySQL / SQLite execute per-statement (DDL implicitly commits). @@ -1471,10 +1464,18 @@ impl SimpleComponent for App { AppMsg::NewTableTab { schema } => self.on_new_table_tab(schema, sender), AppMsg::EditStructureTab { schema, table } => self.on_edit_structure_tab(schema, table, sender), AppMsg::ShowCreateTableForExisting { schema, table } => self.on_show_create_table(schema, table, sender), - AppMsg::ShowCreateTableLoaded { sql } => self.append_editor_tab(Some(sql), sender), + AppMsg::ShowCreateTableLoaded(update) => { + if let Some(sql) = update.if_current(&self.context.active) { + self.append_editor_tab(Some(sql), sender); + } + } AppMsg::DropTablePrompt { schema, table } => self.on_drop_table_prompt(schema, table, sender), AppMsg::DropTableConfirmed { schema, table } => self.on_drop_table_confirmed(schema, table, sender), - AppMsg::DropTableSucceeded { schema, table } => self.on_drop_table_succeeded(schema, table, sender), + AppMsg::DropTableSucceeded(update) => { + if let Some(table) = update.if_current(&self.context.active) { + self.on_drop_table_succeeded(table, sender); + } + } AppMsg::ExecuteStructureTransaction { tab_id, statements } => { self.on_execute_structure_transaction(tab_id, statements, sender) } diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index 4d92d0f60c..151ad6b001 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -23,6 +23,7 @@ use tablepro_core::{ForeignKeyMode, WriteBatch, WriteKind, WriteReport}; use tablepro_session::runtime::TaskFailure; use tablepro_session::{OperationError, OperationKind, Session}; +use crate::services::from_session::FromSession; use crate::ui::app::{App, AppMsg, WorkspaceTab}; use crate::ui::error_text; use crate::ui::structure_tab::{StructureMode, StructureTabInput}; @@ -130,10 +131,12 @@ impl App { ); return; }; - let batch = schema_write(TableRef::new(schema.clone(), table.clone()), &[sql]); + let dropped = TableRef::new(schema, table); + let batch = schema_write(dropped.clone(), &[sql]); + let origin = Arc::downgrade(&session); crate::runtime::spawn_and_deliver(&self.context.tasks, run_schema_write(session, batch), move |result| { match settled(result) { - Ok(_) => sender.input(AppMsg::DropTableSucceeded { schema, table }), + Ok(_) => sender.input(AppMsg::DropTableSucceeded(FromSession::new(origin, dropped))), Err(error) => sender.input(AppMsg::ShowAlert { title: crate::i18n::gettext("Drop failed"), body: error_text::operation_error_message(&error), @@ -184,20 +187,11 @@ impl App { } } - /// Drop succeeded on the driver: now close any open tabs for - /// the dropped table and refresh sidebar. Run synchronously on - /// the GTK main thread so the close + sidebar refresh appear - /// atomic to the user. - pub(super) fn on_drop_table_succeeded( - &mut self, - schema: Option, - table: String, - sender: ComponentSender, - ) { - self.close_tabs_for_table(schema.as_deref(), &table); + pub(super) fn on_drop_table_succeeded(&mut self, table: TableRef, sender: ComponentSender) { + self.close_tabs_for_table(table.schema.as_deref(), &table.name); sender.input(AppMsg::SchemaChanged { - schema, - table: Some(table), + schema: table.schema, + table: Some(table.name), }); } @@ -423,12 +417,13 @@ impl App { return; }; let driver_id = self.driver_id().to_string(); + let origin = Arc::downgrade(&session); let work = read_definition(session, TableRef::new(schema, table.clone())); crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { let message = match settled(result) { Ok((definition, dialect)) => match create_table_sql(&definition, &driver_id, &*dialect) { Ok(Some(sql)) => { - sender.input(AppMsg::ShowCreateTableLoaded { sql }); + sender.input(AppMsg::ShowCreateTableLoaded(FromSession::new(origin, sql))); return; } Ok(None) => crate::i18n::gettext_f("Table {table} has no columns.", &[("table", &table)]), From 326b617c869f9eb8fcd1543c11c306849bf6fda3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 22:18:34 +0700 Subject: [PATCH 100/178] fix(linux): log a table list that failed to refresh once, not twice --- linux/crates/app/src/ui/app/structure.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index 151ad6b001..5715d05cf4 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -483,11 +483,10 @@ impl App { structure.send(StructureTabInput::SchemaChanged); } } - // The new list reaches the sidebar through the catalog watch. + // The catalog watch takes the new list to the sidebar, and logs + // the error of one that could not be read. self.context.tasks.spawn_task(async move { - if let Err(error) = session.refresh_objects(ObjectScope::DefaultSchema).await { - tracing::warn!(%error, "could not reload the table list"); - } + let _ = session.refresh_objects(ObjectScope::DefaultSchema).await; }); } From 5ca7c9d17e809065f266f3aa247a73c8b9229cd9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 22:18:38 +0700 Subject: [PATCH 101/178] docs(linux): list the reconnect recovery of table and structure tabs --- linux/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 71419fd54d..5fd77c9a4e 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Closing a table tab left its query running on the server until it finished. - Refresh did not show a column added to the table since the tab opened. - A table tab kept the old header of a column renamed in the Structure tab. +- A table or structure tab whose read failed while the connection was lost kept its error page after the reconnect. - A refresh that failed replaced the rows in a table tab with an error page. - Switching connections could show the previous connection's tables in the sidebar, or its reconnect banner. - Switching connections during a table drop could close the new connection's tabs on a table of the same name. From 83440ded3f87b65fae4ff7905424d54b58fdc1cb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 22:18:49 +0700 Subject: [PATCH 102/178] style(linux): trim narrative comments in the window and tab code --- .../crates/app/src/runtime/failed_requests.rs | 5 +- .../app/src/runtime/request_sequencer.rs | 3 +- linux/crates/app/src/ui/app/connection.rs | 29 +--- linux/crates/app/src/ui/app/mod.rs | 149 +++++------------- linux/crates/app/src/ui/app/status_pages.rs | 10 +- linux/crates/app/src/ui/app/structure.rs | 35 +--- linux/crates/app/src/ui/app/workspace_tabs.rs | 91 +++-------- linux/crates/app/src/ui/browse_tab/imp.rs | 4 +- linux/crates/app/src/ui/browse_tab/mod.rs | 5 +- .../app/src/ui/browse_tab/page_reply.rs | 3 +- .../app/src/ui/structure_tab/columns.rs | 63 ++------ linux/crates/app/src/ui/structure_tab/imp.rs | 4 +- .../app/src/ui/structure_tab/indexes.rs | 10 +- linux/crates/app/src/ui/structure_tab/mod.rs | 8 +- .../app/src/ui/structure_tab_dialogs.rs | 42 ++--- 15 files changed, 113 insertions(+), 348 deletions(-) diff --git a/linux/crates/app/src/runtime/failed_requests.rs b/linux/crates/app/src/runtime/failed_requests.rs index bd75b25be1..141c7ecd6e 100644 --- a/linux/crates/app/src/runtime/failed_requests.rs +++ b/linux/crates/app/src/runtime/failed_requests.rs @@ -12,7 +12,6 @@ impl FailedRequests { Self { kinds: BTreeSet::new() } } - /// Note how the request's last reply went. pub fn record(&mut self, kind: K, outcome: &Result) { match outcome { Ok(_) => self.kinds.remove(&kind), @@ -28,8 +27,8 @@ impl FailedRequests { self.kinds.is_empty() } - /// Every failed kind, in order, leaving none behind. The caller asks - /// for each again, and the replies record what happens next. + /// Every failed kind, in order, leaving none behind: the replies to + /// the requests asked again record them afresh. pub fn take(&mut self) -> Vec { std::mem::take(&mut self.kinds).into_iter().collect() } diff --git a/linux/crates/app/src/runtime/request_sequencer.rs b/linux/crates/app/src/runtime/request_sequencer.rs index 906aea877e..ba2dc3b1e5 100644 --- a/linux/crates/app/src/runtime/request_sequencer.rs +++ b/linux/crates/app/src/runtime/request_sequencer.rs @@ -9,8 +9,7 @@ use super::{RequestStamp, RequestTicket}; /// /// Issuing a request stops the one of its kind still running, so the /// server drops work nobody will look at, and only the reply to the -/// latest request is accepted. Dropping the sequencer stops them all, -/// which is what closing a view should do. +/// latest request is accepted. Dropping the sequencer stops them all. #[derive(Debug)] pub struct RequestSequencer { last: u64, diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index 454b4b8437..de93fce359 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -439,9 +439,7 @@ impl App { // Persist + tear down workspace tabs before dropping the // session (persist needs the active connection id). self.teardown_workspace_tabs(); - // Drop reopen-stack entries: they reference tables in the - // connection we're about to release. Reopening one against a - // different connection would target a non-existent table. + // The closed tabs name tables of the connection being released. self.clear_closed_tabs_stack(); self.session_links = None; close_session(&self.context.active, &*self.context.hold); @@ -509,13 +507,8 @@ impl App { } pub(super) fn on_delete_connection(&self, id: Uuid, sender: ComponentSender) { - // Connection deletion wipes the saved entry and ALL associated - // keyring credentials (db password, SSH password, SSH passphrase). - // Irreversible (no Undo can recover keyring entries) so we - // confirm unconditionally; the previous `confirm_destructive` - // preference gate let users skip it, but per HIG (and GNOME - // Files' bookmark-delete behaviour) destructive keyring writes - // need confirmation regardless of preferences. + // The delete takes the keyring secrets with it and has no undo, + // so it asks whatever the preferences say. let connection_name = self .saved_connections .iter() @@ -871,14 +864,8 @@ impl App { } pub(super) fn refresh_window_title(&self) { - // Subtitle: " · " when connected, empty - // otherwise. The active table goes in the tab title (where it - // already lives); duplicating it in the WindowTitle subtitle - // both overruns the slot's intended ~7-word capacity and - // pretends the subtitle is the canonical "where am I?" widget - // when the tab strip already serves that role. Matches GNOME - // Builder (subtitle = branch name only) and Text Editor - // (subtitle = filename only): short, single-purpose. + // The table is in the tab title already, so the subtitle leaves + // it out, as GNOME Builder's names only the branch. let metadata = self.context.active.metadata(); let connection_name = metadata.as_ref().map(|m| m.name.as_str()); let active = self.selected_browse_slot_table(); @@ -902,10 +889,8 @@ impl App { self.window.set_title(Some(&os_title)); self.window_title.set_subtitle(&subtitle); - // Sidebar header acts as a breadcrumb: the title shows the - // active connection name when connected, falling back to the - // generic "Tables" label on the welcome screen. Subtitle stays - // empty: the driver / host already lives in the main header. + // The sidebar's header has no subtitle: the main header already + // names the driver. match connection_name { Some(name) => { self.sidebar_title.set_title(name); diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 81184a829b..39034f3e66 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -106,9 +106,7 @@ pub struct App { row_op_spinner: adw::Spinner, read_only_badge: gtk::Label, table_search: gtk::SearchEntry, - /// Outer Stack inside `content_holder`: swaps between `"empty"` - /// (AdwStatusPage "Select a table") and `"tabs"` (the unified - /// AdwTabOverview hosting both Browse and Editor sub-components). + /// `"empty"` while no tab is open, `"tabs"` otherwise. workspace_outer_stack: gtk::Stack, /// AdwTabOverview wrapping the unified AdwTabBar + AdwTabView. /// Built lazily on connect; torn down on disconnect. @@ -132,8 +130,7 @@ pub struct App { welcome_view: Controller, /// Driver id is connection-wide, not per-tab. current_driver_id: Option, - /// All tables in the current connection, fed into `schema_buffer` - /// for the editor's autocomplete; not the per-tab columns. + /// The table names the editor completes; the tabs add their columns. table_names: Vec, /// Read-only flag is connection-wide; fanned out to every BrowseTab /// when toggled. @@ -142,15 +139,9 @@ pub struct App { /// Per-tab page size lives on each BrowseTab. saved_connections: Vec, connected: bool, - /// Tabs the user picked "Save" on in a close-confirmation dialog, - /// counted by remaining saves before the close fires. A Table tab - /// with both browse-dirty AND structure-dirty dispatches two saves - /// (one of each kind) so its entry starts at 2: the first - /// completion decrements to 1 (no close yet), the second decrements - /// to 0 and finally fires `WorkspaceTabClosed`. A `SaveFailed` - /// removes the entry entirely (abort all close intents for that - /// tab so the user can fix the error and retry). See - /// `dec_close_after_save` for the decrement helper. + /// Tabs that close once their saves land, with the number still to + /// land: a Table tab with both row and DDL edits waits on two. A + /// failed save drops the entry, so the tab stays open to fix it. close_after_save: std::rc::Rc>>, /// Set when the user picked "Save" on the *window*-close dialog. /// While true, the last `SaveCompletedForTab` that empties @@ -177,14 +168,9 @@ pub struct App { /// page-size change and state-changed event, and each one would /// otherwise rewrite the whole workspace file. persist_debouncer: crate::workspace::PersistDebouncer, - /// LIFO stack of recently-closed tab descriptors for Ctrl+Shift+T - /// reopen. Capped at `CLOSED_TABS_CAPACITY`; the oldest entry is - /// dropped when a new one is pushed against a full stack. Cleared - /// on disconnect: descriptors reference the active connection's - /// tables, so reopening across connections would target the wrong - /// schema. Editor descriptors round-trip the buffer text; - /// dirty tabs lose their pending row/DDL edits because the - /// trackers have already been cleared by the close path. + /// Recently closed tabs for Ctrl+Shift+T. Cleared on disconnect, + /// since the tables they name belong to that connection. Pending + /// edits are not kept: the close has already cleared them. closed_tabs_stack: std::rc::Rc>>, } @@ -236,12 +222,9 @@ pub struct StructureTabSlot { pub mode: crate::ui::structure_tab::StructureMode, } -/// One workspace tab pinned to a single `(schema, table)` entity in -/// Data (Browse) mode. The Structure (DDL) view is no longer fused -/// into the same tab: `WorkspaceTab::Structure` is its own -/// dedicated tab opened via the sidebar right-click "Edit Structure" -/// action. This split mirrors GNOME's "one TabPage per surface" -/// idiom (Files, Builder) instead of a per-tab AdwViewSwitcher. +/// A tab showing one table's rows. The table's structure opens in a +/// tab of its own: one TabPage per surface, as GNOME Files and Builder +/// do. pub struct TableTabSlot { pub id: Uuid, pub page: adw::TabPage, @@ -256,10 +239,8 @@ pub struct TableTabSlot { /// view. Default for every sidebar single-click. /// - **Editor**: a free-form SQL workspace, orthogonal to any one /// table. -/// - **Structure**: the DDL editor, which opens for "New Table" drafts -/// AND for "Edit Structure" against an existing table (via the -/// sidebar right-click action). Replaces the previous inline -/// AdwViewSwitcher on the Table tab. +/// - **Structure**: the DDL editor, for a new table's draft or an +/// existing table. pub enum WorkspaceTab { Editor(EditorTabSlot), Structure(StructureTabSlot), @@ -297,18 +278,14 @@ impl WorkspaceTab { #[derive(Debug, Clone, Copy)] pub enum OpenMode { - /// Plain sidebar click: if a Browse tab for the table already - /// exists, activate it; otherwise append a new Browse tab. - /// Never closes existing tabs: accumulates until the user dismisses. + /// Plain sidebar click: activate the table's tab, or append one. SwitchOrAppend, /// Ctrl+click / right-click "Open in new tab": always append a /// new tab even when the same table is already open. NewTab, } -// One Quark keyed `tp-workspace-tab-id` covers all tabs in the unified -// workspace. We look up the WorkspaceTab from the HashMap to discover -// kind: qdata only carries identity. +// The page carries only the tab's id; its kind is in `workspace_tabs`. fn workspace_tab_id_quark() -> glib::Quark { static QUARK: std::sync::OnceLock = std::sync::OnceLock::new(); *QUARK.get_or_init(|| glib::Quark::from_str("tp-workspace-tab-id")) @@ -449,9 +426,8 @@ pub enum AppMsg { /// Tab right-click "Close Tabs to the Right" → close every tab /// after the targeted one in TabView display order. CloseWorkspaceTabsToRight(Uuid), - /// Drag-reorder / selection change / browse-tab-state-changed: - /// triggers persistence (writes the current display order + each - /// slot's state to workspace_state.json). + /// The tab order, the selection or a tab's state changed, so the + /// workspace is saved. WorkspaceTabsChanged, /// Save a BrowseTab's pending changes as one batch, planned on the /// connection that runs it. App dispatches @@ -475,16 +451,15 @@ pub enum AppMsg { /// Routed before SaveFailedForTab so the tab can scroll-and-select /// the offending row before the error alert appears. FlashErrorRowForTab(Uuid, crate::services::change_tracker::StatementSource), - /// Ctrl+S: fire CommitSave on the active browse tab. No-op if - /// the active tab is an Editor or there's no active connection. + /// Ctrl+S, for the active Table tab. SaveActiveBrowseTab, /// Targeted variant: close-confirmation dialogs use this to commit /// a specific tab (which may not be the currently-active one when /// the user is closing a background tab via its X button). SaveActiveBrowseTabById(Uuid), - /// Ctrl+Z: undo the last pending change in the active tab. + /// Ctrl+Z, for the active Table tab. UndoActiveBrowseTab, - /// Ctrl+Y: redo a previously undone change in the active tab. + /// Ctrl+Y, for the active Table tab. RedoActiveBrowseTab, /// Show a small alert dialog; used by BrowseTab for "select exactly /// one row" type messages. @@ -492,8 +467,6 @@ pub enum AppMsg { title: String, body: String, }, - /// Show a transient toast, used for inline-validation feedback like - /// "Invalid date format" where a modal alert would be over-heavy. ShowToast(String), /// Tracker for a specific browse tab moved between empty / non-empty. /// Handler updates that tab's page title to add or remove the @@ -545,10 +518,7 @@ pub enum AppMsg { tab_id: Uuid, statements: Vec, }, - /// Targeted save for a specific structure tab (close-with-pending - /// dialog uses this; mirrors `SaveActiveBrowseTabById`). Looks up - /// the slot, materialises the tracker, dispatches - /// `ExecuteStructureTransaction`. + /// Save one Structure tab, which need not be the active one. SaveActiveStructureTabById(Uuid), /// Structure tab Save resolved successfully. `new_table_name` is /// `Some(name)` for `New` mode CreateTable transitions; the tab @@ -596,11 +566,8 @@ impl App { } /// The active driver id, or "postgres" if no connection is active. - /// - /// Single fallback site (was duplicated at 7 call sites). The - /// tracing::warn! makes the latent bug visible if anything ever - /// asks for the driver id without an active connection: today - /// that would silently corrupt SQL quoting on non-Postgres drivers. + /// The fallback would quote SQL wrongly for another engine, so it is + /// logged. pub(super) fn driver_id(&self) -> &str { match self.current_driver_id.as_deref() { Some(id) => id, @@ -687,12 +654,9 @@ impl SimpleComponent for App { set_max_sidebar_width: 280.0, set_show_sidebar: false, - // Sidebar wrapped in its own AdwToolbarView so it can - // carry a sidebar-local AdwHeaderBar with a search - // toggle, the same structure GNOME Files uses for its - // Places sidebar. Window-decoration buttons live on - // the outer (main) header bar already, so we hide - // them here. + // The sidebar has its own header bar for the search + // toggle, as GNOME Files does; the window buttons are + // on the main one. #[wrap(Some)] #[name = "sidebar_root"] set_sidebar = &adw::ToolbarView { @@ -757,14 +721,8 @@ impl SimpleComponent for App { set_revealed: false, set_use_markup: false, }, - // Content is set imperatively at the end of - // init(): show_welcome_page swaps in the - // WelcomeView for the disconnected state, - // and on_connected swaps in the workspace - // tab strip on connect. The previously- - // inlined "Connect to a database" StatusPage - // here was dead UI: built once, replaced - // immediately, never seen. + // The welcome view or the workspace goes in + // here once init() has run. }, }, }, @@ -820,11 +778,8 @@ impl SimpleComponent for App { let context_for_close = context.clone(); let connect_attempts_for_close = connect_attempts.clone(); widgets.window.connect_close_request(move |w| { - // If a Save is mid-flight (async transaction running), block - // the close until it resolves. Without this, the completion - // handler would dispatch SaveCompleted to a tab that's - // already gone: the transaction commits in the background - // with no UI feedback. + // A save still running would commit after its tab is gone, + // with nothing left to say whether it worked. if !force_close_for_close.get() && in_flight_saves_for_close.get() > 0 { let dialog = adw::AlertDialog::new( Some(&crate::i18n::gettext("Saving in progress")), @@ -850,18 +805,8 @@ impl SimpleComponent for App { }); return glib::Propagation::Stop; } - // Already-confirmed close path (set by the dialog handler - // below): skip the guard, save state, allow close. - // Browse + Structure tabs share the dirty-state guard: - // either source of pending changes triggers the dialog. let has_pending = !dirty_tabs(&change_trackers_for_close, &structure_trackers_for_close).is_empty(); if !force_close_for_close.get() && has_pending { - // Plural-form heading matches the per-tab dialog's - // tone: factual GNOME HIG language rather than the - // colloquial "throws them away" the body used to - // carry. Per-tab dialog stays specific ("Save changes - // to {name}"); window close groups across N tabs so - // it stays generic. let dialog = adw::AlertDialog::new(None, None); dialog.set_heading(Some(&crate::i18n::gettext("Save changes before closing?"))); dialog.set_body(&crate::i18n::gettext( @@ -898,8 +843,6 @@ impl SimpleComponent for App { } } force_close_for_resp.set(true); - // Re-fire close_request: guard sees the flag, - // saves window state, returns Proceed. window_for_resp.close(); } "save" => { @@ -912,9 +855,7 @@ impl SimpleComponent for App { // the set drains. Any SaveFailed aborts. let browse_tabs = change_trackers_for_resp.dirty(); let structure_tabs = structure_trackers_for_resp.dirty(); - // Counter increments: a Table tab listed in both - // sets bumps to 2 so the window close waits for - // BOTH the browse save and the structure save. + // A Table tab in both sets waits for both saves. { let mut map = close_after_save_for_resp.borrow_mut(); for id in browse_tabs.iter().copied() { @@ -1057,13 +998,7 @@ impl SimpleComponent for App { .table_search_bar .set_key_capture_widget(Some(&widgets.sidebar_root)); - // Empty-state placeholder. Shown by GtkListBox when no row is - // visible: covers both "the database has zero tables" and - // "the search filtered everything out". Without this, the - // sidebar renders as a blank surface and reads as broken. - // AdwStatusPage `.compact` is the documented empty-state - // widget for narrow containers (matches GNOME Files's - // sidebar-empty look). + // An empty list with no placeholder reads as broken. let sidebar_placeholder = adw::StatusPage::builder() .icon_name(crate::ui::icons::VIEW_LIST) .title(crate::i18n::gettext("No tables")) @@ -1090,12 +1025,8 @@ impl SimpleComponent for App { let schemas = schemas_for_header.borrow(); let total_distinct: std::collections::BTreeSet<&str> = schemas.iter().filter_map(|s| s.as_deref()).collect(); - // Postgres-style multi-schema connections render a header - // per schema with a "+" button for "New Table…". Single- - // schema connections (MySQL / SQLite) get one header - // anchored to "main" / database-name with the same "+" - // affordance: the visual cue matters even when there's - // only one schema in the list. + // A connection with one schema still gets a header, for its + // "New Table" button. let multi_schema = total_distinct.len() >= 2; let idx = row.index(); let current = schemas.get(idx as usize).cloned().flatten(); @@ -1117,10 +1048,7 @@ impl SimpleComponent for App { .margin_top(12) .margin_bottom(6) .margin_start(12) - // Match the row body's `margin_end: 12` so the "+" - // button sits flush with where row content ends: the - // previous 6px pulled it inward of the row label edge - // and read as a misaligned column. + // Lines the "+" up with the end of the row content. .margin_end(12) .build(); let label_text = current @@ -1132,12 +1060,7 @@ impl SimpleComponent for App { .xalign(0.0) .hexpand(true) .build(); - // GtkPlacesSidebar section-header typography: small + bold - // + ~55% alpha. `.heading` (libadwaita's "emphasized body") - // combined with `.dim-label` rendered as bold-dim at body - // size: too loud for a section divider. `.caption-heading` - // is the small-bold variant the toolkit ships for exactly - // this purpose. + // GtkPlacesSidebar's section header: small, bold and dim. label.add_css_class("caption-heading"); label.add_css_class("dim-label"); header_box.append(&label); diff --git a/linux/crates/app/src/ui/app/status_pages.rs b/linux/crates/app/src/ui/app/status_pages.rs index d1b1753494..8cf48615f2 100644 --- a/linux/crates/app/src/ui/app/status_pages.rs +++ b/linux/crates/app/src/ui/app/status_pages.rs @@ -8,17 +8,11 @@ use super::{App, AppMsg}; impl App { pub(super) fn show_welcome_page(&self, _sender: ComponentSender) { - // Welcome lives outside the ViewStack: it's the disconnected mode. - // The ViewSwitcherBar is hidden via on_disconnect so the welcome - // view occupies the full toolbar surface. self.content_holder.set_content(Some(self.welcome_view.widget())); } - /// Used during connect to convey "Connecting…". Persistent toast - /// (timeout 0), held in `connect_progress_toast` until the connect - /// resolves, at which point `dismiss_loading_page` clears it. Replaces - /// the prior fire-and-forget toast which auto-dismissed at 2 s, well - /// before remote / SSH-tunnelled connections resolve. + /// The toast stays until the connect resolves: a remote or tunnelled + /// connect can take longer than any timeout would. pub(super) fn set_loading_page(&mut self, title: &str, description: &str, sender: &ComponentSender) { if let Some(prev) = self.connect_progress_toast.take() { prev.dismiss(); diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index 5715d05cf4..76d4272012 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -38,12 +38,8 @@ impl App { self.append_new_structure_tab(schema, sender); } - /// Sidebar right-click → "Edit Structure". Opens a dedicated - /// Structure tab (separate AdwTabPage). If one already exists for - /// this `(schema, table)` it's just re-selected; otherwise a new - /// `WorkspaceTab::Structure` is appended via - /// `append_existing_structure_tab`. The Data-side Browse tab (if - /// any) is independent: both can stay open side by side. + /// Select the table's Structure tab, or open one. Its Table tab + /// stays open beside it. pub(super) fn on_edit_structure_tab( &mut self, schema: Option, @@ -145,17 +141,11 @@ impl App { }); } - /// Targeted save: commit a specific Structure tab's pending DDL - /// without requiring it to be the active tab. Used by the - /// close-with-pending dialog's "Save" branch: the user might be - /// closing a background tab via its X button. + /// Save one Structure tab's DDL, whether or not it is the active + /// tab. pub(super) fn save_structure_tab_by_id(&mut self, tab_id: Uuid, sender: ComponentSender) { - // If a save is already in flight for this tab, do nothing: - // calling `on_execute_structure_transaction` would silently - // early-return on the duplicate insert, never firing a - // completion. The in-flight save's `on_structure_save_completed` - // (or _failed) drains `close_after_save` for `tab_id`, so the - // close-with-pending flow still resolves through that path. + // The save in flight settles `close_after_save` for this tab; + // a second one would return without ever completing. if self.structure_saves_in_flight.borrow().contains(&tab_id) { tracing::debug!( ?tab_id, @@ -314,8 +304,7 @@ impl App { } self.structure_saves_in_flight.borrow_mut().remove(&tab_id); - // Inspect slot kind first so we can branch (promote-and-close - // vs. in-place clear) without borrowing across mutation. + // Decided first, so the tabs are not borrowed while they change. enum SaveKind { PromoteNewToTable(Option, String), UpdateInPlace(Option, String), @@ -346,12 +335,7 @@ impl App { if let Some(tab_view) = self.workspace_tab_view.clone() { self.finish_close_workspace_tab(tab_id, &tab_view); } - // After the CREATE TABLE lands, drop the user into a - // Browse tab on the freshly-created table: they just - // designed it, so showing Data (empty grid) is the - // natural next step. If they want to keep editing the - // schema, sidebar right-click → "Edit Structure" - // reopens a Structure tab. + // A table just designed is filled in next. super::App::append_table_tab( self, schema.clone(), @@ -505,9 +489,6 @@ impl App { let (page, schema, table_name, combined_dirty) = match slot { WorkspaceTab::Structure(s) => (&s.page, s.schema.as_deref(), s.table.clone(), dirty), WorkspaceTab::Table(s) => { - // Combine with the data-side dirty state: either - // mode contributing pending changes prefixes the tab - // with the "•" GNOME convention. let data = self .change_trackers .get(s.id) diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index 057580e64d..7f703c5e36 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -139,11 +139,7 @@ impl App { sort_col, sort_asc, } => { - // The persisted `mode` field is now ignored: Data - // and Structure are separate AdwTabPages. Restoring - // a Table record always opens the Data side; if the - // user had a Structure tab open, that's captured as - // a separate `WorkspaceTabRecord::Structure` record. + // A Structure tab is a record of its own now. let _ = mode; self.append_table_tab( schema.clone(), @@ -321,10 +317,8 @@ impl App { let page = tab_view.append(&structure); let title = qualified_browse_tab_label(self.sidebar_schemas_distinct(), schema.as_deref(), &table); - // Disambiguate from the Data-side tab with the same base name - // by suffixing the structure tab title: "products · Structure". - // GNOME uses U+00B7 middle dot as the canonical separator - // (Files, Builder, Console all do this for compound titles). + // Told apart from the table's own tab, with the middle dot GNOME + // apps use in compound titles. page.set_title(&format!("{title} · {}", crate::i18n::gettext("Structure"))); write_workspace_tab_id(&page, tab_id); @@ -427,9 +421,6 @@ impl App { WorkspaceTab::Table(s) => s.page.clone(), }); let Some(page) = page else { return }; - // Tab title without the dirty bullet: the heading reads - // as the natural-language name of the tab (`categories`), - // not `• categories`. Strip a leading "• " if present. let raw_title = page.title().to_string(); let tab_label = raw_title.strip_prefix("• ").unwrap_or(&raw_title).to_string(); // Cancel | Discard(destructive) | Save(suggested), Save is @@ -559,12 +550,8 @@ impl App { let Some(removed) = removed else { return; }; - // Snapshot the closed tab into the reopen stack BEFORE running - // the per-kind teardown below: the BrowseModel still has its - // pagination + sort state intact, and the EditorTabSlot still - // owns the latest query buffer. New-mode Structure drafts are - // skipped because the table they describe doesn't exist yet, - // so reopening a draft would be useless. + // Before the teardown, while the tab still has its page, sort + // and query. if let Some(descriptor) = describe_closed_tab(&removed) { push_closed_tab(&self.closed_tabs_stack, descriptor); } @@ -681,21 +668,8 @@ impl App { } } - /// Sidebar-click dispatcher. Two behaviours: - /// - /// - `SwitchOrAppend` (plain click): if a Browse tab for - /// `(schema, table)` is already open, activate it; otherwise - /// append a new Browse tab. Never closes anything: tabs only - /// go away when the user clicks the X. - /// - `NewTab` (Ctrl+click / right-click "Open in new tab"): always - /// append a new tab even if the same table is already open. - /// - /// The earlier "smart-replace" sub-case (close active + append new - /// in one step) was dropped because AdwTabView's close-page - /// animation overlapped with the append, producing a visual flash - /// where the user briefly saw the closing tab and the new tab - /// side by side. Always-append is what every modern DB client - /// (TablePlus, DBeaver, DataGrip, Beekeeper) does anyway. + /// A sidebar click never replaces the active tab: AdwTabView's + /// close animation overlapped the append and flashed both tabs. pub(super) fn dispatch_select_table( &mut self, schema: Option, @@ -866,9 +840,7 @@ fn do_persist_workspace_state( WorkspaceTabRecord::Table { schema: s.schema.clone(), table: s.table.clone(), - // `mode` is a legacy field: Table records always - // describe a Data-side Browse tab now. Structure - // tabs persist as separate `Structure` records. + // A Structure tab is a record of its own now. mode: crate::services::workspace_state::PersistedTableMode::Data, offset: s.browse.current_offset(), page_size: s.browse.page_size(), @@ -895,11 +867,8 @@ impl App { self.sync_sidebar_selection(); } - /// Highlight the sidebar row matching the active Browse tab's - /// `(schema, table)`. When the active tab is an Editor (or there - /// are no tabs), clear the sidebar selection: leaving a stale - /// row highlighted while the user is in the editor would imply - /// the editor is showing that table's data, which it isn't. + /// With no Table tab active, no row stays selected: one would say + /// the editor shows that table. fn sync_sidebar_selection(&self) { let listbox = self.sidebar_factory.widget(); let Some((schema, table)) = self.selected_browse_slot_table() else { @@ -935,10 +904,6 @@ impl App { let id = self.selected_workspace_tab_id()?; let tabs = self.workspace_tabs.borrow(); let slot = tabs.get(&id)?; - // Treat both legacy `Browse` and unified `Table` (regardless of - // active mode) as candidates: the Browse-side controller is - // present in both. Save / Discard / Find paths route through - // this helper. if slot.browse_tab().is_some() { Some(id) } else { None } } @@ -968,17 +933,9 @@ impl App { distinct.len() } - /// Refresh a browse tab's title based on its tracker dirty state. - /// Mirrors GNOME Text Editor's leading "•" prefix convention for - /// unsaved buffers. The base label is recomputed (not stored) so - /// schema disambiguation stays correct if other tabs change. - /// - /// Also flips `set_needs_attention` on the AdwTabPage so the tab - /// bar's pulsing dot appears for background tabs that have unsaved - /// edits: matches AdwTabView's intended use and surfaces the - /// dirty state when the user is in another tab. The flag is - /// suppressed for the currently-selected tab since the user is - /// actively looking at it (the "•" title prefix is enough cue). + /// The label is built again rather than stored, so the schema + /// prefix follows the other tabs. Only a background tab asks for + /// attention; the selected one has its "•" in view. pub(super) fn refresh_browse_tab_dirty(&self, tab_id: uuid::Uuid, dirty: bool) { let schemas_count = self.sidebar_schemas_distinct(); let tabs = self.workspace_tabs.borrow(); @@ -1007,8 +964,7 @@ impl App { } pub(super) fn teardown_workspace_tabs(&mut self) { - // Synchronous flush: debouncer would skip the write since - // teardown drops state before the 500ms timer would fire. + // The debounced write would run after the state is gone. self.do_persist_workspace_state_now(); self.cancel_all_editor_runs(); // The pending edits name rows of the connection going away, so @@ -1100,11 +1056,6 @@ impl App { } pub(super) fn on_replace_active_tab_query(&mut self, text: String, sender: ComponentSender) { - // If an editor tab is active, replace its buffer in-place. If a - // browse tab is active (or no tab at all), fall back to opening - // a new editor tab with the query: silent no-op was the prior - // (annoying) behaviour for users invoking from history while - // browsing. if let Some(id) = self.selected_workspace_tab_id() && let Some(WorkspaceTab::Editor(slot)) = self.workspace_tabs.borrow().get(&id) { @@ -1330,8 +1281,7 @@ fn describe_closed_tab(slot: &WorkspaceTab) -> Option { sort: s.browse.current_sort(), }), WorkspaceTab::Structure(s) => match s.mode { - // New-Table drafts have no entity to point at and lose - // their in-progress DDL on close: nothing to reopen. + // A draft names no table to reopen. StructureMode::New => None, StructureMode::Edit => Some(ClosedTabDescriptor::Structure { schema: s.schema.clone(), @@ -1359,10 +1309,7 @@ impl App { } let descriptor = self.closed_tabs_stack.borrow_mut().pop_back(); let Some(descriptor) = descriptor else { - // Stack empty: nothing to reopen. Toast keeps the - // shortcut from feeling broken when the user hits it - // before having closed anything (or after a reconnect - // wiped the stack). + // Without a toast the shortcut would read as broken. self.show_toast(&crate::i18n::gettext("No recently closed tab")); return; }; @@ -1391,10 +1338,8 @@ impl App { } } -/// Returns a tooltip for an Editor tab. Empty for blank queries; for -/// non-empty queries, a 200-char preview, but only when distinct from -/// the (truncated) label, so non-truncated labels don't get a redundant -/// hover popup. +/// At most 200 characters of the query, and none when the label +/// already shows them. fn editor_tab_tooltip(query: &str, label: &str) -> Option { let q = query.trim(); if q.is_empty() { diff --git a/linux/crates/app/src/ui/browse_tab/imp.rs b/linux/crates/app/src/ui/browse_tab/imp.rs index 6681de7ba6..fd783c868f 100644 --- a/linux/crates/app/src/ui/browse_tab/imp.rs +++ b/linux/crates/app/src/ui/browse_tab/imp.rs @@ -190,8 +190,8 @@ impl ObjectImpl for BrowseTab { }) } - /// Closing the tab stops every read it has running, so the server - /// drops the statement rather than finishing it for nobody. + /// A cancelled read lets the server drop its statement rather than + /// finish it for nobody. fn dispose(&self) { self.link_watch.take(); if let Some(forwarding) = self.grid_forward.take() { diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index c6f06bc516..eae45e2c93 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -254,8 +254,8 @@ impl BrowseTab { } /// Stop every read the tab has running and stop following - /// reconnects. Closing the tab calls this, so the server lets go of - /// the statement even while something still holds the widget. + /// reconnects now, rather than at dispose, which waits for the last + /// reference to the widget. pub fn shutdown(&self) { let imp = self.imp(); imp.link_watch.take(); @@ -541,7 +541,6 @@ struct PageView { limit: u64, } -/// The tab's state and the widgets it updates. pub(crate) struct BrowseModel { this: glib::WeakRef, session: Arc, diff --git a/linux/crates/app/src/ui/browse_tab/page_reply.rs b/linux/crates/app/src/ui/browse_tab/page_reply.rs index a70e193335..3bac9deb07 100644 --- a/linux/crates/app/src/ui/browse_tab/page_reply.rs +++ b/linux/crates/app/src/ui/browse_tab/page_reply.rs @@ -1,7 +1,6 @@ use tablepro_core::QueryResult; use tablepro_core::meta::PagePosition; -/// One page of rows and where in the table it starts. #[derive(Debug, Clone)] pub(crate) struct PageReply { pub position: PagePosition, @@ -9,7 +8,7 @@ pub(crate) struct PageReply { } impl PageReply { - /// The row offset the page starts at, for the paginator. + /// `None` when the page starts at a key or a ctid window. pub fn offset(&self) -> Option { match self.position { PagePosition::First => Some(0), diff --git a/linux/crates/app/src/ui/structure_tab/columns.rs b/linux/crates/app/src/ui/structure_tab/columns.rs index 5b50739a3f..de76a381da 100644 --- a/linux/crates/app/src/ui/structure_tab/columns.rs +++ b/linux/crates/app/src/ui/structure_tab/columns.rs @@ -13,10 +13,8 @@ use tablepro_core::sql_ddl::DraftColumn; use super::{ColumnField, StructureTabInput, StructureTabSender}; -/// Curated type lists per driver. Free-text input still allowed via -/// the combo box's editable entry; this list seeds the dropdown so -/// common types are one click away. Order matters: most-common at -/// the top. +/// The type suggestions per driver, most common first. Any other type +/// can still be typed. pub(super) fn driver_types(driver_id: &str) -> &'static [&'static str] { match driver_id { "postgres" => &[ @@ -118,11 +116,8 @@ pub(super) fn default_type_for(driver_id: &str) -> String { } } -/// Render a draft column's summary line for the collapsed expander -/// header: `varchar(255) · NOT NULL · Primary key`. Order: type, -/// nullability, primary key, auto-increment. Empty parts are skipped -/// so a freshly-added column with default state shows the minimum -/// useful information. +/// The collapsed row's summary, such as `varchar(255) · NOT NULL · +/// Primary key`. fn format_column_subtitle(col: &DraftColumn) -> String { let mut parts: Vec = Vec::new(); if !col.data_type.trim().is_empty() { @@ -142,19 +137,12 @@ fn format_column_subtitle(col: &DraftColumn) -> String { parts.join(" · ") } -/// Build one collapsible column row as `adw::ExpanderRow`. Collapsed -/// state shows the column name (title) + summary subtitle; expanded -/// reveals one `AdwEntryRow` / `AdwSwitchRow` per editable attribute. -/// SQLite-restricted fields render as `set_sensitive(false)` with -/// explanatory tooltips so the user understands why they can't edit. -/// Non-original (newly-added) columns always allow full editing: -/// those become `AddColumn` ops which SQLite accepts at execution. +/// A field SQLite cannot alter is insensitive, with a tooltip saying +/// why; a new column is fully editable, since SQLite accepts it as an +/// `ADD COLUMN`. /// -/// `suppress_emit` lets the caller mark a window during which signal -/// callbacks should NOT push edits onto the model. Used during -/// `rebuild_columns_view` to silence the `changed` / `toggled` -/// emissions GTK fires while initial values are stamped onto the -/// freshly-built widgets. +/// While `suppress_emit` is set, the signals GTK fires as the initial +/// values go in are not taken for edits. pub(super) fn build_column_expander_row( index: usize, col: &DraftColumn, @@ -172,8 +160,7 @@ pub(super) fn build_column_expander_row( .build(); row.set_widget_name(&format!("col-row-{index}")); - // Trash button as a header-suffix on the expander row itself, which - // remains visible whether the row is expanded or collapsed. + // On the header, so it shows whether or not the row is expanded. let remove_button = gtk::Button::builder() .icon_name(crate::ui::icons::USER_TRASH) .tooltip_text(crate::i18n::gettext("Remove column")) @@ -211,10 +198,8 @@ pub(super) fn build_column_expander_row( }); row.add_row(&name_row); - // Type (AdwEntryRow, free text). A suffix MenuButton offers the - // curated `driver_types()` suggestions; free-text input remains the - // primary path so custom types like `decimal(10,2)` or Postgres - // `enum` literals work without enumeration. + // Free text, so `decimal(10,2)` or an enum type need no list; the + // menu only suggests. let type_row = adw::EntryRow::builder().title(crate::i18n::gettext("Type")).build(); type_row.set_text(&col.data_type); if limit_for_existing && !driver_can_alter_existing_column(driver_id) { @@ -360,27 +345,11 @@ pub(super) fn build_column_expander_row( row } -/// Build a suffix MenuButton for the type AdwEntryRow that opens a -/// native `gtk::PopoverMenu` listing curated `driver_types()` for -/// `driver_id`. Selecting an entry rewrites the target row's text -/// (which fires the row's `changed` signal; the existing handler -/// picks up the new value). Free-text input via the entry stays as -/// the primary path. +/// A picked type is written into `target`, whose `changed` handler +/// takes it like typed text. Each button has its own action group, so +/// two columns' menus never share the `types.apply` action. /// -/// Implementation: a `gio::Menu` model + `MenuButton.set_menu_model` -/// causes GTK to render a `gtk::PopoverMenu` automatically. That is -/// the same widget powering app menus, right-click menus, and -/// gnome-menus across the desktop, so the rendering is identical to -/// every other GNOME menu the user has ever seen: proper menu-item -/// padding, hover/active states, separators, focus ring, all native. -/// -/// Each type-name menu item activates a single SimpleAction -/// (`types.apply`) parameterised by the type string. The action is -/// stored in a per-button action group so two columns' menus don't -/// collide on the action name. -/// -/// Returns the button plus the auto-built PopoverMenu so the caller -/// can pop it down before the row goes. +/// The popover comes back too, to be popped down before its row goes. fn build_type_suggestions_button(driver_id: &str, target: &adw::EntryRow) -> (gtk::MenuButton, gtk::Popover) { // Per-button action group: one action `apply` keyed by `String` // parameter. Each menu item activates `types.apply::`. diff --git a/linux/crates/app/src/ui/structure_tab/imp.rs b/linux/crates/app/src/ui/structure_tab/imp.rs index 93467e1086..fb0f633f02 100644 --- a/linux/crates/app/src/ui/structure_tab/imp.rs +++ b/linux/crates/app/src/ui/structure_tab/imp.rs @@ -92,8 +92,8 @@ impl ObjectImpl for StructureTab { }) } - /// Closing the tab stops its read and lets go of the theme handler - /// on the app-wide style manager. + /// The model holds a handler on the app-wide style manager, which + /// would otherwise outlive the tab. fn dispose(&self) { self.link_watch.take(); if let Ok(mut model) = self.model.try_borrow_mut() diff --git a/linux/crates/app/src/ui/structure_tab/indexes.rs b/linux/crates/app/src/ui/structure_tab/indexes.rs index 855cf5cef1..74042bf438 100644 --- a/linux/crates/app/src/ui/structure_tab/indexes.rs +++ b/linux/crates/app/src/ui/structure_tab/indexes.rs @@ -10,9 +10,6 @@ use tablepro_core::meta::IndexKey; use super::{StructureTabInput, StructureTabSender}; -/// Tiny inline pill rendered as an AdwActionRow suffix, used by the -/// indexes list to show UNIQUE / PRIMARY tags. `accent_class` is the -/// CSS class controlling the colour (`dim-label`, `accent`, etc.). fn index_badge(label: &str, accent_class: &str) -> gtk::Label { let badge = gtk::Label::builder().label(label).valign(gtk::Align::Center).build(); badge.add_css_class("caption"); @@ -20,10 +17,6 @@ fn index_badge(label: &str, accent_class: &str) -> gtk::Label { badge } -/// Index row as a native `AdwActionRow`: title is the index name, -/// subtitle is the comma-separated column list. UNIQUE / PRIMARY are -/// small caption suffixes, the trash button is an end-suffix. The row -/// participates in `boxed-list` styling for free; no manual margins. pub(super) fn build_index_row(index: usize, idx: &IndexInfo, sender: StructureTabSender) -> adw::ActionRow { let subtitle = index_key_summary(idx); let row = adw::ActionRow::builder() @@ -44,8 +37,7 @@ pub(super) fn build_index_row(index: usize, idx: &IndexInfo, sender: StructureTa .valign(gtk::Align::Center) .build(); remove_button.add_css_class("flat"); - // Primary index isn't user-droppable: it's owned by the PK - // column constraint and removing it breaks the table. + // The primary key's constraint owns its index. if idx.primary { remove_button.set_sensitive(false); remove_button.set_tooltip_text(Some(&crate::i18n::gettext( diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index 149f1052a0..e4ff8eaf53 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -95,8 +95,8 @@ pub struct StructureTabInit { pub tasks: Tasks, } -/// What the rows and dialogs of a tab use to reach it. It holds the tab -/// weakly, so a row's closure never keeps a closed tab alive. +/// Holds the tab weakly, so a row's or a dialog's closure never keeps a +/// closed tab alive. #[derive(Clone)] pub(crate) struct StructureTabSender { tab: glib::WeakRef, @@ -256,7 +256,9 @@ impl StructureTab { imp.dispatching.set(false); } - /// Stop the read the tab has running and stop following reconnects. + /// Stop the read the tab has running and stop following reconnects + /// now, rather than at dispose, which waits for the last reference + /// to the widget. pub fn shutdown(&self) { let imp = self.imp(); imp.link_watch.take(); diff --git a/linux/crates/app/src/ui/structure_tab_dialogs.rs b/linux/crates/app/src/ui/structure_tab_dialogs.rs index 08c5fc2f1b..317cf4295a 100644 --- a/linux/crates/app/src/ui/structure_tab_dialogs.rs +++ b/linux/crates/app/src/ui/structure_tab_dialogs.rs @@ -1,19 +1,9 @@ //! Add-Index and Add-Foreign-Key form dialogs for the Structure tab. //! -//! Split out of `structure_tab.rs` so that file can stay focused on -//! the SimpleComponent itself. Both dialogs follow the same skeleton -//! (`build_form_dialog`): an `adw::Dialog` carrying a custom HeaderBar -//! with Cancel + suggested-action buttons and a vertically-scrolling -//! content box. Per HIG, AlertDialog is for confirmation prompts; data -//! entry forms belong on AdwDialog with explicit headerbar buttons. -//! -//! Form fields use AdwEntryRow / AdwSwitchRow / AdwComboRow inside an -//! AdwPreferencesGroup, the same pattern GNOME Settings uses for -//! every "Add account / Add network / Add printer" dialog. Raw -//! GtkEntry + sibling labels was the quick-MVP layout but it doesn't -//! pick up the rounded-corner / row-separator styling and breaks -//! typing flow (the title float of AdwEntryRow doubles as the -//! placeholder, halving vertical space). +//! A form is an `adw::Dialog` with Cancel and a suggested action in +//! its header bar: the HIG keeps AlertDialog for confirmations. Its +//! fields are rows in an AdwPreferencesGroup, as in GNOME Settings' +//! "Add" dialogs. use std::cell::RefCell; use std::rc::Rc; @@ -93,22 +83,14 @@ fn build_form_dialog(title: &str, submit_label: &str) -> (adw::Dialog, gtk::Box, (dialog, content, submit_btn) } -/// Build a section header label sized as a small caption, used -/// above sub-groupings inside form dialogs. AdwPreferencesGroup -/// already handles its own header, so this is for the "Columns" -/// label that sits above the column checklist (which is itself a -/// boxed-list, not a PreferencesGroup). +/// A heading for the column checklist, which is a boxed list rather +/// than a PreferencesGroup with a title of its own. fn section_label(title: &str) -> gtk::Label { let label = gtk::Label::builder().label(title).xalign(0.0).build(); label.add_css_class("heading"); label } -/// Build a boxed-list `ListBox` of `AdwActionRow` + `CheckButton`, -/// one per draft column, for picking columns inside a form dialog. -/// Returns the list (so the caller can `body.append` it) plus the -/// shared `Rc` of name/check pairs so the submit handler can extract -/// which columns the user ticked. fn build_column_checklist(columns: &[DraftColumn]) -> (gtk::ListBox, ColumnChecks) { let list = gtk::ListBox::builder().selection_mode(gtk::SelectionMode::None).build(); list.add_css_class("boxed-list"); @@ -205,8 +187,6 @@ pub(super) fn present_fk_dialog( let (dialog, body, submit_btn) = build_form_dialog(&crate::i18n::gettext("Add Foreign Key"), &crate::i18n::gettext("Add")); - // Name in its own AdwPreferencesGroup at the top, which matches the - // shape of the column-edit drawer + every other GNOME form. let name_group = adw::PreferencesGroup::builder().build(); let name_row = adw::EntryRow::builder() .title(crate::i18n::gettext("Name")) @@ -219,9 +199,8 @@ pub(super) fn present_fk_dialog( let (columns_list, column_checks) = build_column_checklist(columns); body.append(&columns_list); - // Reference target + ON DELETE / ON UPDATE in a second group. - // Reference columns stay free-text: wiring up an async fetch of - // the referenced table's columns is out of scope for an MVP. + // The referenced columns are typed: the dialog does not read the + // other table. let ref_group = adw::PreferencesGroup::builder() .title(crate::i18n::gettext("References")) .build(); @@ -288,9 +267,8 @@ pub(super) fn present_fk_dialog( ))); return; } - // Source and reference column counts must match: `(a, b) → (x)` - // is structurally invalid SQL. Drivers reject it, but with an - // opaque error after Save instead of an inline guard. + // The server would reject it too, but only at Save and in its + // own words. if ref_cols.len() != cols.len() { sender_for_resp.output(StructureTabOutput::ShowToast(crate::i18n::gettext( "Source and reference column counts must match.", From d80dc1074765ca5decb04f573d93826a6f0ebb1b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 22:37:37 +0700 Subject: [PATCH 103/178] refactor(linux-grid): move the data grid into a ui/grid module --- linux/crates/app/src/ui/browse_tab/mod.rs | 46 +- linux/crates/app/src/ui/browse_tab/tests.rs | 2 +- linux/crates/app/src/ui/grid.rs | 2713 ----------------- .../app/src/ui/{ => grid}/cell_editor.rs | 61 +- .../app/src/ui/grid/cell_editor_kind.rs | 71 + linux/crates/app/src/ui/grid/cell_popovers.rs | 316 ++ linux/crates/app/src/ui/grid/cell_preset.rs | 5 + .../crates/app/src/ui/grid/column_factory.rs | 787 +++++ .../app/src/ui/grid/column_width_binding.rs | 31 + linux/crates/app/src/ui/grid/context_menu.rs | 128 + linux/crates/app/src/ui/grid/display_text.rs | 201 ++ linux/crates/app/src/ui/grid/grid_menus.rs | 469 +++ linux/crates/app/src/ui/grid/grid_msg.rs | 36 + linux/crates/app/src/ui/grid/inline_edit.rs | 187 ++ linux/crates/app/src/ui/grid/mod.rs | 26 + .../crates/app/src/ui/grid/row_json_dialog.rs | 43 + .../app/src/ui/{ => grid}/row_object.rs | 13 +- linux/crates/app/src/ui/grid/selection.rs | 83 + .../app/src/ui/grid/tab_grid_context.rs | 110 + linux/crates/app/src/ui/grid/widget_slot.rs | 46 + linux/crates/app/src/ui/mod.rs | 2 - linux/po/POTFILES.in | 20 +- 22 files changed, 2589 insertions(+), 2807 deletions(-) delete mode 100644 linux/crates/app/src/ui/grid.rs rename linux/crates/app/src/ui/{ => grid}/cell_editor.rs (64%) create mode 100644 linux/crates/app/src/ui/grid/cell_editor_kind.rs create mode 100644 linux/crates/app/src/ui/grid/cell_popovers.rs create mode 100644 linux/crates/app/src/ui/grid/cell_preset.rs create mode 100644 linux/crates/app/src/ui/grid/column_factory.rs create mode 100644 linux/crates/app/src/ui/grid/column_width_binding.rs create mode 100644 linux/crates/app/src/ui/grid/context_menu.rs create mode 100644 linux/crates/app/src/ui/grid/display_text.rs create mode 100644 linux/crates/app/src/ui/grid/grid_menus.rs create mode 100644 linux/crates/app/src/ui/grid/grid_msg.rs create mode 100644 linux/crates/app/src/ui/grid/inline_edit.rs create mode 100644 linux/crates/app/src/ui/grid/mod.rs create mode 100644 linux/crates/app/src/ui/grid/row_json_dialog.rs rename linux/crates/app/src/ui/{ => grid}/row_object.rs (69%) create mode 100644 linux/crates/app/src/ui/grid/selection.rs create mode 100644 linux/crates/app/src/ui/grid/tab_grid_context.rs create mode 100644 linux/crates/app/src/ui/grid/widget_slot.rs diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index eae45e2c93..20ae865f32 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -34,7 +34,7 @@ use tablepro_session::{LinkGeneration, Session}; pub(crate) use self::browse_command::{BrowseCommand, ColumnsReply}; pub(crate) use self::browse_request_kind::BrowseRequestKind; pub(crate) use self::page_reply::PageReply; -use super::grid::{CellPreset, GridMsg, TabGridContext, build_column_view}; +use super::grid::{CellEditor, CellPreset, ColumnWidthBinding, GridMsg, RowObject, TabGridContext, build_column_view}; use crate::runtime::{FailedRequests, RequestSequencer}; use crate::services::change_tracker::{RowKey, StatementSource, TrackedChanges}; use crate::services::tab_tracker_handle::TabTrackerHandle; @@ -751,14 +751,12 @@ impl BrowseModel { fn grid_context(&self) -> TabGridContext { TabGridContext { tracker: Some(self.tracker.clone()), - column_widths: self - .connection_id - .map(|connection_id| crate::ui::grid::ColumnWidthBinding { - store: self.column_widths.clone(), - connection_id, - schema: self.schema.clone(), - table: self.table.clone(), - }), + column_widths: self.connection_id.map(|connection_id| ColumnWidthBinding { + store: self.column_widths.clone(), + connection_id, + schema: self.schema.clone(), + table: self.table.clone(), + }), pk_col_indices: self.pk_indices(), } } @@ -930,16 +928,16 @@ impl BrowseModel { if let (Some(store), Some(store_pos), Some(old)) = (store, store_pos, row_obj) { let cells = old.cells_clone(); let replacement = match old.draft_id() { - Some(id) => super::row_object::RowObject::new_draft(id, cells), - None => super::row_object::RowObject::new(cells), + Some(id) => RowObject::new_draft(id, cells), + None => RowObject::new(cells), }; store.splice(store_pos, 1, &[replacement]); } } - fn row_object_at(&self, position: u32) -> Option { + fn row_object_at(&self, position: u32) -> Option { let model = self.current_selection.as_ref()?.model()?; - model.item(position)?.downcast::().ok() + model.item(position)?.downcast::().ok() } /// Where a row is in the selection model: a draft by its id, a @@ -953,7 +951,7 @@ impl BrowseModel { let n_items = model.n_items(); for i in 0..n_items { let Some(item) = model.item(i) else { continue }; - let Ok(row) = item.downcast::() else { + let Ok(row) = item.downcast::() else { continue; }; let matched = match key { @@ -1010,9 +1008,7 @@ impl BrowseModel { } if let Some(selection) = selection_for_clear && let Some(model) = selection.model() - && let Some(row_obj) = model - .item(position) - .and_then(|o| o.downcast::().ok()) + && let Some(row_obj) = model.item(position).and_then(|o| o.downcast::().ok()) && let Some(store) = model.downcast::().ok() && let Some(store_pos) = store.find(&row_obj) { @@ -1041,7 +1037,7 @@ impl BrowseModel { let pos = bitset.nth(0); let Some(model) = selection.model() else { return }; let Some(item) = model.item(pos) else { return }; - let Ok(row) = item.downcast::() else { + let Ok(row) = item.downcast::() else { return; }; let key = if let Some(draft_id) = row.draft_id() { @@ -1092,7 +1088,7 @@ impl BrowseModel { let Some(focused) = gtk::prelude::GtkWindowExt::focus(&window) else { return; }; - if let Ok(label) = focused.dynamic_cast::() { + if let Ok(label) = focused.dynamic_cast::() { if label.text().as_str() == super::grid::editable_null_sentinel() { label.set_text(""); } @@ -1193,7 +1189,7 @@ impl BrowseModel { fn refresh_grid_data(&self, result: &QueryResult, store: >k::gio::ListStore) { store.remove_all(); for row in &result.rows { - store.append(&super::row_object::RowObject::new(row.clone())); + store.append(&RowObject::new(row.clone())); } self.reprepend_drafts(); } @@ -1236,10 +1232,7 @@ impl BrowseModel { return; }; for draft in self.tracker.drafts() { - store.insert( - 0, - &super::row_object::RowObject::new_draft(draft.draft_id, draft.values), - ); + store.insert(0, &RowObject::new_draft(draft.draft_id, draft.values)); } } @@ -1802,7 +1795,7 @@ impl BrowseModel { let mut had_persisted_row = false; for pos in &positions { let Some(item) = model.item(*pos) else { continue }; - let Ok(row_obj) = item.downcast::() else { + let Ok(row_obj) = item.downcast::() else { continue; }; if let Some(draft_id) = row_obj.draft_id() { @@ -1878,7 +1871,7 @@ impl BrowseModel { let rows: Vec> = positions .iter() .filter_map(|pos| model.item(*pos)) - .filter_map(|item| item.downcast::().ok()) + .filter_map(|item| item.downcast::().ok()) .map(|row| ctx.effective_cells(&row)) .collect(); if rows.is_empty() { @@ -1902,7 +1895,6 @@ fn qualified(schema: Option<&str>, table: &str) -> String { } fn apply_visual_change(store: >k::gio::ListStore, change: &VisualChange) { - use super::row_object::RowObject; let draft_at = |draft_id: u64| { (0..store.n_items()).find_map(|position| { let row = store.item(position).and_downcast::()?; diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index ebff2b1e99..6024817be9 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -18,7 +18,7 @@ use super::{ use crate::services::change_tracker::RowKey; use crate::services::tracker_delta::VisualChange; use crate::test_support::{FakeSession, drain_main_context, wait_until}; -use crate::ui::row_object::RowObject; +use crate::ui::grid::RowObject; const WAIT: Duration = Duration::from_secs(10); diff --git a/linux/crates/app/src/ui/grid.rs b/linux/crates/app/src/ui/grid.rs deleted file mode 100644 index 25128b2ba8..0000000000 --- a/linux/crates/app/src/ui/grid.rs +++ /dev/null @@ -1,2713 +0,0 @@ -use std::cell::RefCell; -use std::rc::Rc; - -use chrono::Datelike; -use gtk4::prelude::*; -use gtk4::{self as gtk, gio, glib}; -use relm4::adw; -use relm4::adw::prelude::*; -use sourceview5::prelude::*; - -use tablepro_core::column::ResultColumn; -use tablepro_core::{ColumnInfo, QueryResult, Value}; - -use super::row_object::RowObject; -use crate::services::cell_overlay::CellOverlay; -use crate::services::change_tracker::{CellState, RowKey, RowState}; -use crate::services::tab_tracker_handle::TabTrackerHandle; - -#[derive(Debug)] -pub enum GridMsg { - /// User clicked a column header. `(col_idx, ascending)` is the - /// resolved post-click state read from the GtkColumnViewSorter - /// — not a "toggle" hint. Reading the sorter directly avoids - /// the receiver having to keep its own toggle state in sync, - /// which broke when both `primary-sort-column` and - /// `primary-sort-order` notify fired for the same logical - /// click (column change resets order; two events, two flips). - SortChanged(usize, bool), - CellEdited { - row_position: u32, - col_index: usize, - new_value: String, - }, - CopyToClipboard(String), - /// Something the user asked for could not be done in full: an IN - /// clause with nothing left to put in it, a preset the column - /// cannot hold. The owning tab surfaces it as a toast. - ShowToast(String), - CopyRowAsInsert { - row_position: u32, - }, - /// "Set Value" names the intent, not a `Value`: the grid does not - /// know the column's declared type, and writing `Text("")` into an - /// `int` or a `date` column produces an UPDATE the server rejects. - /// The owning tab resolves the preset against its column metadata. - SetCellValue { - row_position: u32, - col_index: usize, - preset: CellPreset, - }, - ExportResults(QueryResult), - DeleteRowAt { - row_position: u32, - }, - /// Context-menu "Insert row" — forwarded by browse_tab as - /// `BrowseTabInput::InsertRow`. Same effect as the toolbar Insert - /// button or Ctrl+N. - InsertRow, - /// "Duplicate row" — create a fresh draft row whose cell values - /// are pre-populated from the source row. The browse tab's - /// handler reads the source row from the result snapshot and - /// pushes a draft via the change tracker; user can edit before - /// Save. PK / generated / auto-increment columns are blanked so - /// the duplicate doesn't inherit the source's identity. - DuplicateRow { - row_position: u32, - }, -} - -/// What the "Set Value" submenu can put in a cell. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CellPreset { - Empty, - Null, -} - -/// What a browse grid draws pending edits from. `tracker` is `None` for -/// a grid nothing tracks, such as editor results. -#[derive(Debug, Clone, Default)] -pub struct TabGridContext { - pub tracker: Option, - pub pk_col_indices: Vec, - /// Where a dragged column edge is remembered. `None` for a grid - /// with nothing to remember it against, such as editor results. - pub column_widths: Option, -} - -/// Which table's widths a grid reads and writes. -/// -/// The schema is part of it, so `public.users` and `audit.users` keep -/// their own widths. -#[derive(Clone)] -pub struct ColumnWidthBinding { - pub store: crate::persistence::ColumnWidthStore, - pub connection_id: uuid::Uuid, - pub schema: Option, - pub table: String, -} - -impl std::fmt::Debug for ColumnWidthBinding { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ColumnWidthBinding") - .field("connection_id", &self.connection_id) - .field("schema", &self.schema) - .field("table", &self.table) - .finish() - } -} - -impl ColumnWidthBinding { - fn width(&self, column: &str) -> Option { - self.store - .width(self.connection_id, self.schema.as_deref(), &self.table, column) - } - - fn record(&self, column: &str, width: i32) { - self.store - .record(self.connection_id, self.schema.as_deref(), &self.table, column, width); - } -} - -impl TabGridContext { - /// The tracker and the row's key in it. `None` when nothing tracks - /// the grid or a persisted row has no usable primary key. - fn tracked_key(&self, row: &RowObject) -> Option<(&TabTrackerHandle, RowKey)> { - let tracker = self.tracker.as_ref()?; - let key = match row.draft_id() { - Some(draft_id) => RowKey::Draft(draft_id), - None => { - let pk_values: Vec = self.pk_col_indices.iter().map(|&i| row.cell_value(i)).collect(); - RowKey::from_pk_values(&pk_values)? - } - }; - Some((tracker, key)) - } - - /// The whole row as the grid is showing it. Every copy and export - /// path reads this rather than `RowObject::cells_clone`, which for - /// a persisted row holds the untouched values the fetch returned. - pub(super) fn effective_cells(&self, row: &RowObject) -> Vec { - let raw = row.cells_clone(); - let Some((tracker, key)) = self.tracked_key(row) else { - return raw; - }; - raw.iter() - .enumerate() - .map(|(col, value)| tracker.cell_overlay(&key, col, value).value) - .collect() - } - - /// The value a bind draws and the pending-state classes it carries. - fn bind_state(&self, row: &RowObject, col: usize) -> (Value, Vec<&'static str>) { - let raw = row.cell_value(col); - let Some((tracker, key)) = self.tracked_key(row) else { - return (raw, Vec::new()); - }; - let overlay = tracker.cell_overlay(&key, col, &raw); - let classes = pending_classes(tracker.row_state(&key), &overlay, col); - (overlay.value, classes) - } -} - -/// Pending state shows as the row's tint alone: insert green, modified -/// orange, delete red with a strikethrough. -fn pending_classes(row_state: RowState, overlay: &CellOverlay, col: usize) -> Vec<&'static str> { - let mut classes = Vec::new(); - match (row_state, overlay.state) { - (RowState::PendingDelete, _) => classes.push("tp-row-pending-delete"), - (RowState::InsertDraft, _) => classes.push("tp-row-pending-insert"), - (_, CellState::Modified) => classes.push("tp-cell-modified"), - _ => {} - } - if col == 0 && overlay.error_flash { - classes.push("tp-row-leftmost-error-flash"); - } - classes -} - -pub fn build_column_view( - result: &QueryResult, - schema_columns: &[ColumnInfo], - sender: relm4::Sender, - editable: bool, - sort: Option<(usize, bool)>, - sort_sender: Option>, - tab_ctx: TabGridContext, -) -> (gtk::ColumnView, gtk::MultiSelection) { - let store = gtk4::gio::ListStore::new::(); - for row in &result.rows { - store.append(&RowObject::new(row.clone())); - } - let selection = gtk::MultiSelection::new(Some(store)); - let column_view = gtk::ColumnView::builder() - .model(&selection) - .show_row_separators(true) - .show_column_separators(true) - .build(); - - let grid_menus = install_grid_context_menus(GridMenuInit { - column_view: &column_view, - sender: sender.clone(), - columns: result.columns.clone(), - truncated: result.truncated, - tab_ctx: tab_ctx.clone(), - editable, - }); - - // For wide tables (~9+ columns) the default `expand: true` per - // column shares the viewport fractionally and produces 20-30px - // cells that can't show even short values. When a column has no - // persisted width we fall back to a minimum starting width so the - // grid is usable on first render; the user can still resize from - // there and the new width persists. Narrow tables (≤8 columns) - // keep the expand-to-fill behaviour because there's enough room - // for every column to render readably. - let default_min_width = if result.columns.len() > WIDE_TABLE_THRESHOLD { - Some(MIN_COLUMN_WIDTH_PX) - } else { - None - }; - let mut columns: Vec = Vec::with_capacity(result.columns.len()); - for (i, column) in result.columns.iter().enumerate() { - // The catalogue is the authority on what can be edited: it - // knows the keys, the generated columns and the defaults. Until - // it has loaded, the type alone decides, which is what a - // result column can say. - let cell_editable = editable - && match schema_columns.get(i) { - Some(catalog) => is_cell_editable(catalog), - None => !is_bytes_kind(&column.column_type.kind), - }; - let col = build_column( - column, - i, - cell_editable, - schema_columns - .get(i) - .is_some_and(|catalog| catalog.is_auto_increment || catalog.is_generated), - sender.clone(), - sort_sender.clone(), - tab_ctx.clone(), - default_min_width, - column_view.downgrade(), - grid_menus.clone(), - ); - column_view.append_column(&col); - columns.push(col); - } - - // Apply the inbound sort state BEFORE wiring the signal so the resulting - // primary-sort change doesn't echo back as a SortChanged dispatch. - if let Some((col_idx, ascending)) = sort - && let Some(col) = columns.get(col_idx) - { - let direction = if ascending { - gtk::SortType::Ascending - } else { - gtk::SortType::Descending - }; - column_view.sort_by_column(Some(col), direction); - } - - if let Some(app_sender) = sort_sender - && let Some(view_sorter) = column_view - .sorter() - .and_then(|s| s.downcast::().ok()) - { - // Wire BOTH `primary-sort-column` AND `primary-sort-order` - // notify. Listening only to the former misses the case - // where the user clicks the same column a second time: - // GTK keeps `primary-sort-column` constant and just flips - // `primary-sort-order` (Ascending ↔ Descending). The - // chevron updates because GTK manages it internally, but - // without an order-notify listener the model never sees - // SortChanged, `current_sort` stays stale, and the next - // FetchPage issues the same ORDER BY as the previous one. - // - // Reading both column AND order off the sorter (rather - // than dispatching a "toggle" hint) makes the receiver's - // job idempotent: clicking a different column fires both - // notifies in some order, but each carries the post-state - // pair, and the receiver short-circuits when the pair - // already matches `current_sort`. - let dispatch = { - let app_sender = app_sender.clone(); - let columns = columns.clone(); - move |sorter: >k::ColumnViewSorter| { - let Some(active) = sorter.primary_sort_column() else { - return; - }; - let ascending = matches!(sorter.primary_sort_order(), gtk::SortType::Ascending); - for (idx, col) in columns.iter().enumerate() { - if col == &active { - app_sender.send(GridMsg::SortChanged(idx, ascending)).ok(); - break; - } - } - } - }; - view_sorter.connect_primary_sort_column_notify({ - let dispatch = dispatch.clone(); - move |sorter| dispatch(sorter) - }); - view_sorter.connect_primary_sort_order_notify(move |sorter| dispatch(sorter)); - } - - (column_view, selection) -} - -#[expect( - clippy::too_many_arguments, - reason = "each argument is captured by a different column factory closure" -)] -fn build_column( - info: &ResultColumn, - idx: usize, - editable: bool, - // Whether the server fills this column in, so a draft row shows a - // placeholder rather than an empty cell the user should type in. - auto_filled: bool, - sender: relm4::Sender, - sort_sender: Option>, - tab_ctx: TabGridContext, - default_min_width: Option, - column_view: glib::WeakRef, - grid_menus: GridMenus, -) -> gtk::ColumnViewColumn { - let factory = gtk::SignalListItemFactory::new(); - - let column_data_type = info.column_type.name.clone(); - let column_name = info.name.clone(); - let accepts_empty = info.column_type.kind.accepts_empty_string(); - let column_view_for_setup = column_view.clone(); - factory.connect_setup(move |_, item| { - let Some(item) = item.downcast_ref::() else { - return; - }; - if !editable { - setup_readonly_cell(item, idx, column_name.clone(), &column_view_for_setup, &grid_menus); - return; - } - // Type-specific cell widgets per HIG: - // - Bool → GtkCheckButton (single-click toggles, native - // Space, no edit-mode dance). - // - Date → CellEditor for display + GtkCalendar - // popover for edit (no inline typing; user picks a day). - // - Other types → CellEditor + text parsing on - // commit (parse_input_for_column on the receiving side - // coerces to the right native Value variant). - if is_bool_type(&column_data_type) { - setup_bool_cell( - item, - idx, - column_name.clone(), - sender.clone(), - &column_view_for_setup, - &grid_menus, - ); - } else { - let editor_kind = classify_editor_kind(&column_data_type); - setup_editable_cell( - item, - idx, - column_name.clone(), - sender.clone(), - editor_kind, - accepts_empty, - &column_view_for_setup, - &grid_menus, - ); - } - }); - - let editable_for_bind = editable; - // Columns the database auto-fills on INSERT (auto-increment PKs, - // generated columns) render their NULL placeholder as `(auto)` - // rather than the generic `` / `NULL` sentinels — the user - // shouldn't think those cells are "stored as null", they're - // computed by the DB at commit time. Captured by value so the - // bind closure doesn't borrow `info`. - let column_auto_filled = auto_filled; - let tab_ctx_for_bind = tab_ctx.clone(); - factory.connect_bind(move |_, item| { - let Some(item) = item.downcast_ref::() else { - return; - }; - let Some(row) = item.item().and_downcast::() else { - return; - }; - let (value, pending_classes) = tab_ctx_for_bind.bind_state(&row, idx); - let is_null = matches!(value, Value::Null); - // Editable cells render NULL as the italic sentinel — - // distinguishes a true NULL from an empty string visually. - // Read-only cells use the regular display path which already - // shows "NULL" in dim text. Auto-filled columns (auto-increment - // PKs, generated columns) render NULL as `(auto)` so a draft - // INSERT row reads as "DB will compute this", not "stored as - // null". - let text = if is_null && column_auto_filled { - auto_filled_sentinel() - } else if editable_for_bind { - if is_null { - editable_null_sentinel() - } else { - value_to_edit_text(&value) - } - } else { - value_to_display_text(&value) - }; - - let is_pending_delete = pending_classes.contains(&"tp-row-pending-delete"); - let Some(child) = item.child() else { return }; - if let Ok(label) = child.clone().downcast::() { - label.set_text(&text); - apply_cell_tooltip(label.upcast_ref(), &text, is_null); - if is_null && !editable_for_bind { - label.add_css_class("dim-label"); - } else { - label.remove_css_class("dim-label"); - } - // Italic-dim render of / (auto) sentinels — the - // shared italic class signals "this isn't a literal value - // typed by the user" regardless of which sentinel rendered. - if is_null && (editable_for_bind || column_auto_filled) { - label.add_css_class("tp-null-sentinel"); - } else { - label.remove_css_class("tp-null-sentinel"); - } - clear_pending_classes(label.upcast_ref()); - for cls in &pending_classes { - label.add_css_class(cls); - } - label.set_strikethrough(is_pending_delete); - POSITION_SLOT.set(&label, item.position()); - } else if let Ok(checkbox) = child.clone().downcast::() { - // Bool cell: render via active / inconsistent state. Suppress - // the toggled signal during programmatic set so the bind - // doesn't echo as a synthetic CellEdited event. - SUPPRESS_SLOT.set(&checkbox, true); - match value { - Value::Bool(true) => { - checkbox.set_inconsistent(false); - checkbox.set_active(true); - } - Value::Bool(false) => { - checkbox.set_inconsistent(false); - checkbox.set_active(false); - } - Value::Null => { - checkbox.set_inconsistent(true); - checkbox.set_active(false); - } - _ => { - // Defensive: a non-bool value in a bool column means - // the driver returned an unexpected type. Render - // unchecked + inconsistent so the user sees something - // is off rather than a confidently-wrong checkbox. - checkbox.set_inconsistent(true); - checkbox.set_active(false); - } - } - SUPPRESS_SLOT.set(&checkbox, false); - clear_pending_classes(checkbox.upcast_ref()); - for cls in &pending_classes { - checkbox.add_css_class(cls); - } - // Bool cells can't render a strikethrough on the box; dim - // via opacity for pending-delete instead. - checkbox.set_opacity(if is_pending_delete { 0.5 } else { 1.0 }); - POSITION_SLOT.set(&checkbox, item.position()); - } else if let Ok(label) = child.downcast::() { - label.set_text(&text); - apply_cell_tooltip(label.upcast_ref(), &text, is_null); - if is_null { - label.add_css_class("dim-label"); - } else { - label.remove_css_class("dim-label"); - } - clear_pending_classes(label.upcast_ref()); - for cls in &pending_classes { - label.add_css_class(cls); - } - set_label_strikethrough(&label, is_pending_delete); - POSITION_SLOT.set(&label, item.position()); - } - }); - - factory.connect_unbind(|_, item| { - let Some(item) = item.downcast_ref::() else { - return; - }; - let Some(child) = item.child() else { return }; - if let Ok(label) = child.clone().downcast::() { - if label.is_editing() { - label.stop_editing(false); - } - // Pop down any open popover (calendar / spin / JSON) so it - // unparents itself before the cell is recycled. Otherwise - // GTK warns "Finalizing widget, but it still has children - // left: GtkPopover" on parent destruction. - if let Some(popover) = POPOVER_SLOT.take(&label) { - popover.popdown(); - } - POSITION_SLOT.take(&label); - SNAPSHOT_SLOT.take(&label); - } else if let Ok(checkbox) = child.clone().downcast::() { - POSITION_SLOT.take(&checkbox); - } else if let Ok(label) = child.downcast::() { - POSITION_SLOT.take(&label); - } - }); - - let column = gtk::ColumnViewColumn::builder() - .title(&info.name) - .factory(&factory) - .resizable(true) - .expand(true) - .build(); - if sort_sender.is_some() { - let dummy = gtk::CustomSorter::new(|_, _| gtk::Ordering::Equal); - column.set_sorter(Some(&dummy)); - } - if let Some(widths) = tab_ctx.column_widths.clone() { - if let Some(saved) = widths.width(&info.name) { - column.set_fixed_width(saved); - } else if let Some(min) = default_min_width { - // No persisted width: seed with the wide-table fallback so - // the column starts readable. The user can still resize, - // and connect_fixed_width_notify will persist the new - // value the moment it changes. - column.set_fixed_width(min); - } - let column_name = info.name.clone(); - column.connect_fixed_width_notify(move |column| { - widths.record(&column_name, column.fixed_width()); - }); - } else if let Some(min) = default_min_width { - // Editor result grids run without a connection_id (no - // persistence), but the wide-table problem still applies. - column.set_fixed_width(min); - } - column -} - -/// Setup an editable cell. Display widget is `super::cell_editor::CellEditor`, -/// a `Stack[Label | Text]` glib subclass. The `Label` page is shown at -/// rest; `start_editing()` switches the stack to the `Text` page and -/// the `editing-notify` callback fires `CellEdited` if the text changed. -/// -/// We require a deliberate double-click rather than the widget default -/// (focus-then-click) because clicks in a data grid are routinely -/// row-selection clicks — a single-click trigger would silently drop -/// the user into edit mode on the cell they happened to land on. -#[expect( - clippy::too_many_arguments, - reason = "each argument is captured by a different cell signal handler" -)] -fn setup_editable_cell( - item: >k::ListItem, - idx: usize, - column_name: String, - sender: relm4::Sender, - editor_kind: CellEditorKind, - accepts_empty: bool, - column_view: &glib::WeakRef, - menus: &GridMenus, -) { - let label = super::cell_editor::CellEditor::new(); - label.set_hexpand(true); - label.set_margin_start(8); - label.set_margin_end(8); - // Stash the column index so keyboard shortcuts (Ctrl+Shift+N) - // can resolve `(row, col)` from the focused widget. POSITION_SLOT - // is written by connect_bind because the position changes as - // rows scroll-recycle. - COLUMN_SLOT.set(&label, idx); - item.set_child(Some(&label)); - - attach_cell_gesture( - label.upcast_ref(), - column_view, - CellMenuTarget { - col_index: idx, - column_name, - editable: true, - text_editable: true, - accepts_empty, - }, - menus, - ); - install_edit_commit_handler(&label, idx, sender.clone()); - install_edit_triggers(&label, idx, sender, editor_kind); -} - -/// Bool cells render as a real `gtk::CheckButton`. Click toggles, Space -/// toggles (native), no edit-mode dance. The toggled signal emits a -/// `GridMsg::CellEdited` with a "true"/"false" payload that -/// `parse_input_for_column` upgrades to `Value::Bool` on the receiving -/// side. CheckButton's native focus ring already distinguishes it from -/// the row selection, so this path doesn't need the cell focus-ring CSS -/// added in C1. -fn setup_bool_cell( - item: >k::ListItem, - idx: usize, - column_name: String, - sender: relm4::Sender, - column_view: &glib::WeakRef, - menus: &GridMenus, -) { - let checkbox = gtk::CheckButton::builder() - .halign(gtk::Align::Start) - .valign(gtk::Align::Center) - .margin_start(8) - .margin_end(8) - .build(); - COLUMN_SLOT.set(&checkbox, idx); - item.set_child(Some(&checkbox)); - - attach_cell_gesture( - checkbox.upcast_ref(), - column_view, - CellMenuTarget { - col_index: idx, - column_name, - editable: true, - text_editable: false, - accepts_empty: false, - }, - menus, - ); - checkbox.connect_toggled(move |cb| { - // Suppress the echo while the bind callback is driving the - // checkbox programmatically. - if SUPPRESS_SLOT.get(cb).unwrap_or(false) { - return; - } - let position = POSITION_SLOT.get(cb).unwrap_or(0); - let new_value = if cb.is_active() { "true" } else { "false" }; - sender - .send(GridMsg::CellEdited { - row_position: position, - col_index: idx, - new_value: new_value.to_string(), - }) - .ok(); - }); -} - -/// Flip the cell into edit mode, clearing the `` sentinel -/// first so the user types into an empty entry rather than over -/// the sentinel string. Centralised so every entry path -/// (double-click, F2, Enter, context-menu Edit) behaves the same. -fn enter_edit_mode(label: &super::cell_editor::CellEditor) { - if label.text().as_str() == editable_null_sentinel() { - label.set_text(""); - } - label.start_editing(); -} - -/// The user-visible "NULL" sentinel rendered in editable cells. Goes -/// through `tr!` so locales that prefer a different convention can -/// translate it; the bracketed form is the canonical English variant -/// that keeps the sentinel visually distinct from a literal "NULL" -/// text value. -pub(crate) fn editable_null_sentinel() -> String { - crate::i18n::gettext("") -} - -/// Read-only NULL rendering. Separate from the editable sentinel so -/// translators can localise both forms independently — read-only -/// cells dim the text and don't need the angle-bracket disambig. -pub(crate) fn readonly_null_sentinel() -> String { - crate::i18n::gettext("NULL") -} - -/// Placeholder text rendered in a NULL cell whose value the database -/// computes on INSERT (auto-increment primary keys, generated columns). -/// Carries the "DB will fill this" semantic that "NULL" doesn't — -/// a freshly added draft row no longer reads as if its id is "stored -/// as null". Italic styling comes from the `tp-null-sentinel` CSS -/// class applied alongside. -pub(crate) fn auto_filled_sentinel() -> String { - crate::i18n::gettext("(auto)") -} - -/// Detect whether a column's declared data_type is boolean. Mirrors -/// the bool branch of `classify_type` in browse_tab; kept local here -/// to avoid pulling browse_tab into grid's compile graph for one fn. -fn is_bool_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - matches!(dt.as_str(), "bool" | "boolean" | "bit" | "tinyint(1)") -} - -/// CSS classes that connect_bind toggles per pending-state. Centralised -/// here so the three cell-widget branches (Label, CellEditor, -/// CheckButton) clear the same set without drift. -const PENDING_CSS_CLASSES: &[&str] = &[ - "tp-cell-modified", - "tp-row-pending-delete", - "tp-row-pending-insert", - "tp-row-leftmost-error-flash", -]; - -fn clear_pending_classes(widget: >k::Widget) { - for cls in PENDING_CSS_CLASSES { - widget.remove_css_class(cls); - } -} - -/// Set a tooltip on a cell widget when its text is long enough that -/// the column may visibly truncate it. The cell label uses Pango -/// ellipsization (`set_ellipsize(EllipsizeMode::End)`) — once a column -/// is narrower than the text, the trailing characters disappear with -/// a "…". The tooltip lets the user hover to see the full value. -/// -/// Threshold: `TOOLTIP_MIN_CHARS` covers typical 200px column widths -/// at standard font scaling. Below it the text reliably fits and the -/// tooltip would be redundant. NULL cells skip the tooltip — the -/// rendered "NULL" / "" sentinels are already short and a -/// tooltip on every NULL would be visual noise on a column of -/// nullable values. -const TOOLTIP_MIN_CHARS: usize = 40; - -/// Column count above which a per-column minimum starting width is -/// applied (in absence of persisted widths). Below this threshold, -/// the default `expand: true` shares the viewport without producing -/// unreadably-narrow cells; above it, fractional sharing becomes the -/// dominant problem. -const WIDE_TABLE_THRESHOLD: usize = 8; -/// Per-column minimum width applied when a wide table has no saved -/// widths. Picked to fit ~14 chars at the standard GTK font scale -/// (a typical short identifier or numeric value); the user can -/// resize from there and the new value persists. -const MIN_COLUMN_WIDTH_PX: i32 = 120; - -fn apply_cell_tooltip(widget: >k::Widget, text: &str, is_null: bool) { - if is_null || text.chars().take(TOOLTIP_MIN_CHARS + 1).count() <= TOOLTIP_MIN_CHARS { - widget.set_tooltip_text(None); - } else { - widget.set_tooltip_text(Some(text)); - } -} - -/// Apply or clear a Pango strikethrough attribute on a `GtkLabel`. -/// Used in preference to CSS `text-decoration: line-through` because -/// GTK4's CSS engine doesn't reliably cascade text-decoration through -/// the `CellEditor`'s internal `Stack > Label` structure. -fn set_label_strikethrough(label: >k::Label, on: bool) { - if on { - let attrs = gtk::pango::AttrList::new(); - attrs.insert(gtk::pango::AttrInt::new_strikethrough(true)); - label.set_attributes(Some(&attrs)); - } else { - label.set_attributes(None); - } -} - -/// Detect whether a column's declared data_type is a plain SQL date -/// (no time component). Datetime, timestamp, and timestamptz columns -/// fall through to text-edit because a calendar widget alone can't -/// capture time + zone — those use the standard CellEditor + ISO -/// 8601 parser. -fn is_date_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - dt == "date" || (dt.starts_with("date") && !dt.contains("datetime") && !dt.contains("time")) -} - -/// Detect whether a column is integer-typed (excludes `tinyint(1)` -/// which is bool — caller must check `is_bool_type` first). -fn is_int_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - matches!( - dt.as_str(), - "int" - | "int2" - | "int4" - | "int8" - | "integer" - | "smallint" - | "bigint" - | "tinyint" - | "mediumint" - | "serial" - | "bigserial" - | "smallserial" - ) || dt.starts_with("int(") - || dt.starts_with("integer(") - || dt.starts_with("smallint(") - || dt.starts_with("bigint(") - || dt.starts_with("mediumint(") -} - -/// Detect whether a column is floating-point typed. Decimal / -/// numeric / money are left out because `SqlDecimal` keeps every digit -/// and `GtkSpinButton` holds an f64. -fn is_float_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - matches!(dt.as_str(), "float" | "double" | "real" | "double precision") || dt.starts_with("float(") -} - -/// Detect whether a column is JSON-typed. -fn is_json_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - dt.contains("json") -} - -/// Per-type cell editor selection. Bool is handled separately via -/// `setup_bool_cell` (CheckButton) and never reaches `setup_editable_cell`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CellEditorKind { - /// Default `CellEditor` with text parsing on commit. - Text, - /// `GtkCalendar` popover, day-selected commits ISO 8601 date. - Date, - /// `GtkSpinButton` popover with integer-only adjustment. - Int, - /// `GtkSpinButton` popover with floating-point adjustment. - Float, - /// `GtkSourceView` popover with json language and Save button. - Json, -} - -fn classify_editor_kind(data_type: &str) -> CellEditorKind { - // Bool is filtered out at the call site (handled by setup_bool_cell). - // Order matters: json check before generic int/float since the type - // strings overlap occasionally on contrived schemas. - if is_date_type(data_type) { - CellEditorKind::Date - } else if is_json_type(data_type) { - CellEditorKind::Json - } else if is_int_type(data_type) { - CellEditorKind::Int - } else if is_float_type(data_type) { - CellEditorKind::Float - } else { - CellEditorKind::Text - } -} - -/// Ask the window to advance focus by one step in the given direction. -/// `child_focus` on the root window mirrors what a real Tab keypress -/// would do, so the next focusable widget (next cell, with the -/// default `TAB_ALL` behaviour on `GtkColumnView`) gets the cursor. -fn move_focus(widget: &impl IsA, direction: gtk::DirectionType) { - let Some(root) = widget.root() else { return }; - let Ok(window) = root.dynamic_cast::() else { - return; - }; - window.child_focus(direction); -} - -fn setup_readonly_cell( - item: >k::ListItem, - idx: usize, - column_name: String, - column_view: &glib::WeakRef, - menus: &GridMenus, -) { - let label = gtk::Label::builder() - .xalign(0.0) - .hexpand(true) - .selectable(true) - .ellipsize(gtk::pango::EllipsizeMode::End) - .margin_start(8) - .margin_end(8) - .build(); - item.set_child(Some(&label)); - attach_cell_gesture( - label.upcast_ref(), - column_view, - CellMenuTarget { - col_index: idx, - column_name, - editable: false, - text_editable: false, - accepts_empty: false, - }, - menus, - ); -} - -/// Capture-phase double-click + key handler bundle. Routes F2 / Enter -/// / double-click to either text-edit mode (default) or a date-picker -/// popover, depending on the column type. Tab / Shift+Tab during text -/// edit commit and traverse cells. The two installs share a single -/// `Rc` trigger so the date-popover capture only happens once. -/// -/// Capture phase on the gesture is required because `ColumnView`'s -/// row-selection logic absorbs press events in the default Bubble -/// phase before they can reach this cell-level controller. -fn install_edit_triggers( - label: &super::cell_editor::CellEditor, - col_index: usize, - sender: relm4::Sender, - editor_kind: CellEditorKind, -) { - let trigger: std::rc::Rc = match editor_kind { - CellEditorKind::Text => std::rc::Rc::new(|l: &super::cell_editor::CellEditor| enter_edit_mode(l)), - CellEditorKind::Date => { - let sender = sender.clone(); - std::rc::Rc::new(move |l| show_calendar_popover(l, col_index, &sender)) - } - CellEditorKind::Int => { - let sender = sender.clone(); - std::rc::Rc::new(move |l| show_spin_button_popover(l, col_index, &sender, false)) - } - CellEditorKind::Float => { - let sender = sender.clone(); - std::rc::Rc::new(move |l| show_spin_button_popover(l, col_index, &sender, true)) - } - CellEditorKind::Json => { - let sender = sender.clone(); - std::rc::Rc::new(move |l| show_json_popover(l, col_index, &sender)) - } - }; - - // Double-click → trigger. Capture phase so we beat the ColumnView - // row-selection gesture that runs in Bubble. - // - // The handlers read the cell from their controller: the cell owns - // both controllers, so capturing it would be a cycle. - let gesture = gtk::GestureClick::builder().button(gtk::gdk::BUTTON_PRIMARY).build(); - gesture.set_propagation_phase(gtk::PropagationPhase::Capture); - let trigger_for_press = trigger.clone(); - gesture.connect_pressed(move |gesture, n_press, _, _| { - if n_press != 2 { - return; - } - let Some(label) = gesture.widget().and_downcast::() else { - return; - }; - gesture.set_state(gtk::EventSequenceState::Claimed); - trigger_for_press(&label); - }); - label.add_controller(gesture); - - // Keyboard model: - // - Not editing: F2 / Return / KP_Enter → trigger (start text edit, - // open calendar / spin / json popover, etc.). - // - Editing (only reachable for `CellEditorKind::Text` since other - // kinds use popovers and never enter CellEditor edit mode): - // Tab / Shift+Tab commit + traverse. Return / Esc handled by - // CellEditor's built-in entry handlers. - let controller = gtk::EventControllerKey::new(); - let trigger_for_key = trigger; - controller.connect_key_pressed(move |controller, keyval, _, modifiers| { - let Some(label) = controller.widget().and_downcast::() else { - return glib::Propagation::Proceed; - }; - let editing = label.is_editing(); - let shift = modifiers.contains(gtk::gdk::ModifierType::SHIFT_MASK); - - if !editing { - match keyval { - gtk::gdk::Key::F2 | gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter => { - trigger_for_key(&label); - return glib::Propagation::Stop; - } - // Tab / Shift+Tab when NOT editing: traverse cells. - // Without this they'd fall through to GTK's default - // focus chain and escape the grid to the paginator. - gtk::gdk::Key::Tab if !shift => { - move_focus(&label, gtk::DirectionType::TabForward); - return glib::Propagation::Stop; - } - gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab if shift => { - move_focus(&label, gtk::DirectionType::TabBackward); - return glib::Propagation::Stop; - } - // Left / Right arrow cell navigation. ColumnView's - // built-in Up/Down handles row selection; we add the - // horizontal axis to match spreadsheet convention. - gtk::gdk::Key::Right => { - move_focus(&label, gtk::DirectionType::TabForward); - return glib::Propagation::Stop; - } - gtk::gdk::Key::Left => { - move_focus(&label, gtk::DirectionType::TabBackward); - return glib::Propagation::Stop; - } - _ => {} - } - return glib::Propagation::Proceed; - } - - match keyval { - gtk::gdk::Key::Tab if !shift => { - label.stop_editing(true); - move_focus(&label, gtk::DirectionType::TabForward); - glib::Propagation::Stop - } - gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab if shift => { - label.stop_editing(true); - move_focus(&label, gtk::DirectionType::TabBackward); - glib::Propagation::Stop - } - _ => glib::Propagation::Proceed, - } - }); - label.add_controller(controller); -} - -/// Open a `GtkCalendar` popover anchored to the cell. Pre-selects the -/// cell's current date if the displayed text is a parseable ISO 8601 -/// date; otherwise the calendar shows today's month with no selection. -/// `day-selected` formats YYYY-MM-DD and emits `GridMsg::CellEdited`. -fn show_calendar_popover(label: &super::cell_editor::CellEditor, col_index: usize, sender: &relm4::Sender) { - let calendar = gtk::Calendar::new(); - if let Ok(parsed) = chrono::NaiveDate::parse_from_str(label.text().as_str(), "%Y-%m-%d") - && let Ok(dt) = glib::DateTime::from_local(parsed.year(), parsed.month() as i32, parsed.day() as i32, 0, 0, 0.0) - { - calendar.set_date(&dt); - } - - let popover = gtk::Popover::builder().child(&calendar).build(); - popover.set_parent(label); - POPOVER_SLOT.set(label, popover.clone()); - - let label_for_cal = label.downgrade(); - let popover_for_cal = popover.downgrade(); - let sender_for_cal = sender.clone(); - calendar.connect_day_selected(move |c| { - let (Some(label_for_cal), Some(popover_for_cal)) = (label_for_cal.upgrade(), popover_for_cal.upgrade()) else { - return; - }; - let dt = c.date(); - // glib::DateTime month is 1-based; chrono format directly. - let formatted = format!("{:04}-{:02}-{:02}", dt.year(), dt.month(), dt.day_of_month()); - let position = POSITION_SLOT.get(&label_for_cal).unwrap_or(0); - // Update the label text so the visual changes immediately - // even if the tracker round-trip is async. - label_for_cal.set_text(&formatted); - sender_for_cal - .send(GridMsg::CellEdited { - row_position: position, - col_index, - new_value: formatted, - }) - .ok(); - popover_for_cal.popdown(); - }); - - install_popover_close_cleanup(label, &popover); - popover.popup(); -} - -/// Open a `GtkSpinButton` popover anchored to the cell. Pre-fills from -/// the current cell text. Enter (the spin button's `activate` signal) -/// commits and closes; Esc / click-outside cancels. `is_float = true` -/// configures the adjustment for fractional values with 6 digits of -/// precision; `false` for integers (no decimals). Decimal columns -/// stay on the text-edit path because GtkSpinButton holds an f64 and -/// would drop digits a `SqlDecimal` keeps. -fn show_spin_button_popover( - label: &super::cell_editor::CellEditor, - col_index: usize, - sender: &relm4::Sender, - is_float: bool, -) { - let current_text = label.text().to_string(); - let (initial, lower, upper, step, digits) = if is_float { - let val = current_text.parse::().unwrap_or(0.0); - (val, f64::MIN, f64::MAX, 0.1_f64, 6_u32) - } else { - let val = current_text.parse::().unwrap_or(0) as f64; - (val, i64::MIN as f64, i64::MAX as f64, 1.0_f64, 0_u32) - }; - let adjustment = gtk::Adjustment::new(initial, lower, upper, step, step * 10.0, 0.0); - let spin = gtk::SpinButton::new(Some(&adjustment), step, digits); - spin.set_numeric(true); - spin.set_width_chars(20); - - let popover = gtk::Popover::builder().child(&spin).build(); - popover.set_parent(label); - POPOVER_SLOT.set(label, popover.clone()); - - let label_for_commit = label.downgrade(); - let popover_for_commit = popover.downgrade(); - let sender_for_commit = sender.clone(); - spin.connect_activate(move |s| { - let (Some(label_for_commit), Some(popover_for_commit)) = - (label_for_commit.upgrade(), popover_for_commit.upgrade()) - else { - return; - }; - // Format faithfully to the column kind: integers as "{}", - // floats as f64::Display (drops trailing zeros, no fixed - // precision so we don't lie about precision we don't have). - let formatted = if is_float { - format!("{}", s.value()) - } else { - format!("{}", s.value() as i64) - }; - let position = POSITION_SLOT.get(&label_for_commit).unwrap_or(0); - label_for_commit.set_text(&formatted); - sender_for_commit - .send(GridMsg::CellEdited { - row_position: position, - col_index, - new_value: formatted, - }) - .ok(); - popover_for_commit.popdown(); - }); - - install_popover_close_cleanup(label, &popover); - popover.popup(); - spin.grab_focus(); -} - -/// Open a `GtkSourceView` popover for editing JSON. Multi-line, json -/// language for syntax highlighting, monospace font, line numbers. -/// Explicit Save button (the buffer is multi-line so Enter inserts a -/// newline rather than committing). Esc / click-outside cancels. -fn show_json_popover(label: &super::cell_editor::CellEditor, col_index: usize, sender: &relm4::Sender) { - let buffer = sourceview5::Buffer::new(None); - if let Some(lang) = sourceview5::LanguageManager::default().language("json") { - buffer.set_language(Some(&lang)); - } - buffer.set_text(label.text().as_str()); - - let view = sourceview5::View::with_buffer(&buffer); - view.set_show_line_numbers(true); - view.set_monospace(true); - view.set_auto_indent(true); - view.set_tab_width(2); - view.set_indent_width(2); - - let scrolled = gtk::ScrolledWindow::builder() - .child(&view) - .min_content_width(420) - .min_content_height(280) - .has_frame(true) - .build(); - - let save_button = gtk::Button::with_label(&crate::i18n::gettext("Save")); - save_button.add_css_class("suggested-action"); - - let cancel_button = gtk::Button::with_label(&crate::i18n::gettext("Cancel")); - - let button_box = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(8) - .halign(gtk::Align::End) - .build(); - button_box.append(&cancel_button); - button_box.append(&save_button); - - let container = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .margin_top(8) - .margin_bottom(8) - .margin_start(8) - .margin_end(8) - .build(); - container.append(&scrolled); - container.append(&button_box); - - let popover = gtk::Popover::builder().child(&container).build(); - popover.set_parent(label); - POPOVER_SLOT.set(label, popover.clone()); - - let popover_for_cancel = popover.downgrade(); - cancel_button.connect_clicked(move |_| { - if let Some(popover) = popover_for_cancel.upgrade() { - popover.popdown(); - } - }); - - let label_for_commit = label.downgrade(); - let popover_for_commit = popover.downgrade(); - let sender_for_commit = sender.clone(); - let buffer_for_commit = buffer.clone(); - save_button.connect_clicked(move |_| { - let (Some(label_for_commit), Some(popover_for_commit)) = - (label_for_commit.upgrade(), popover_for_commit.upgrade()) - else { - return; - }; - let start = buffer_for_commit.start_iter(); - let end = buffer_for_commit.end_iter(); - let text = buffer_for_commit.text(&start, &end, true).to_string(); - let position = POSITION_SLOT.get(&label_for_commit).unwrap_or(0); - // Show the new text in the cell immediately. parse_input_for_column - // on the receiving side validates and rejects with a toast if - // the JSON is malformed. - label_for_commit.set_text(&text); - sender_for_commit - .send(GridMsg::CellEdited { - row_position: position, - col_index, - new_value: text, - }) - .ok(); - popover_for_commit.popdown(); - }); - - install_popover_close_cleanup(label, &popover); - popover.popup(); - view.grab_focus(); -} - -/// Wire `popover.connect_closed` to unparent the popover and clear the -/// per-cell `POPOVER_SLOT`. Used by all three editor popovers -/// (calendar, spin button, JSON sourceview) to share one lifecycle. -fn install_popover_close_cleanup(label: &super::cell_editor::CellEditor, popover: >k::Popover) { - // The cell holds the popover in its slot and as its child, so the - // popover holds the cell weakly and itself not at all. - let label_for_close = label.downgrade(); - popover.connect_closed(move |p| { - p.unparent(); - if let Some(label) = label_for_close.upgrade() { - POPOVER_SLOT.take(&label); - } - }); -} - -/// Wire the editing-notify signal: snapshot the original text on -/// entry, commit (or skip if unchanged) on exit. `CellEditor` only -/// switches to edit mode through `start_editing()`, so there's no -/// implicit click-to-edit path to disarm here. -/// -/// IME-safe: while the inner GtkText has an active preedit (CJK -/// composition, ibus / fcitx / ime-mode entries), we ignore the -/// `editing-notify(false)` event — it fires when focus shifts to -/// the IME popover. The preedit-changed handler clears the gate -/// when composition completes; the next `editing-notify(false)` -/// after that fires the real commit. -fn install_edit_commit_handler( - label: &super::cell_editor::CellEditor, - col_index: usize, - sender: relm4::Sender, -) { - // The cell's edit-mode child is a `gtk::Text`; that child owns - // the IME context and emits preedit-changed. - { - let text = label.entry(); - // The entry is the cell's own child, so it holds the cell weakly. - let label_for_preedit = label.downgrade(); - let sender_for_preedit = sender.clone(); - text.connect_preedit_changed(move |_t, preedit| { - let Some(label_for_preedit) = label_for_preedit.upgrade() else { - return; - }; - let active = !preedit.is_empty(); - PREEDIT_SLOT.set(&label_for_preedit, active); - // If preedit just cleared and editing has already ended - // (focus left to the IME popover then back), fire the - // pending commit now. - if !active && !label_for_preedit.is_editing() { - commit_cell_edit(&label_for_preedit, col_index, &sender_for_preedit); - } - }); - } - - label.connect_editing_notify(move |label| { - if label.is_editing() { - let position = POSITION_SLOT.get(label).unwrap_or(0); - let original = label.text().to_string(); - SNAPSHOT_SLOT.set(label, EditSnapshot { position, original }); - return; - } - // Defer commit while an IME preedit is still pending — the - // preedit-changed handler will trigger the commit when - // composition finishes. - if PREEDIT_SLOT.get(label).unwrap_or(false) { - return; - } - commit_cell_edit(label, col_index, &sender); - }); -} - -/// Commit the current `CellEditor` text via `GridMsg::CellEdited` -/// if it differs from the snapshot taken at edit-mode entry. Shared -/// between the editing-notify path and the IME preedit-cleared path -/// so both produce identical tracker state. -fn commit_cell_edit(label: &super::cell_editor::CellEditor, col_index: usize, sender: &relm4::Sender) { - let Some(snap) = SNAPSHOT_SLOT.take(label) else { - return; - }; - let new_value = label.text().to_string(); - if new_value == snap.original { - return; - } - sender - .send(GridMsg::CellEdited { - row_position: snap.position, - col_index, - new_value, - }) - .ok(); -} - -/// What was right-clicked. Filled by `attach_cell_gesture` immediately -/// before the popover is shown; read by every action handler in the -/// shared `cell` action group. The Edit-cell affordance is governed -/// by `GridMenus::edit_action`'s enabled state (toggled per-press), -/// not a field here — the action group is the source of truth, the -/// popover renders the menu item only when the action is enabled. -#[derive(Clone)] -struct CellContext { - widget: gtk::Widget, - col_index: usize, - column_name: String, -} - -/// The right-clicked cell as the shared menu keeps it. The cell holds -/// the menu through its gesture, so the menu holds the cell weakly. -struct ClickedCell { - widget: glib::WeakRef, - col_index: usize, - column_name: String, -} - -impl ClickedCell { - fn get(&self) -> Option { - Some(CellContext { - widget: self.widget.upgrade()?, - col_index: self.col_index, - column_name: self.column_name.clone(), - }) - } -} - -/// What one cell wants from the shared menu: which popover it shows, -/// and which of the per-cell actions apply to it. -#[derive(Clone)] -struct CellMenuTarget { - col_index: usize, - column_name: String, - editable: bool, - text_editable: bool, - accepts_empty: bool, -} - -/// The actions whose enabled state is a property of the right-clicked -/// cell rather than of the grid. Both their menu items carry -/// `hidden-when="action-disabled"`, so a cell they don't apply to -/// shows a menu without them rather than a menu with dead entries. -/// `None` on a read-only grid, which registers neither. -#[derive(Clone, Default)] -struct CellActions { - edit: Option, - set_empty: Option, -} - -impl CellActions { - fn arm(&self, target: &CellMenuTarget) { - if let Some(edit) = &self.edit { - edit.set_enabled(target.text_editable); - } - if let Some(set_empty) = &self.set_empty { - set_empty.set_enabled(target.accepts_empty); - } - } -} - -#[derive(Clone)] -pub(super) struct GridMenus { - context: Rc>>, - editable_popover: gtk::PopoverMenu, - readonly_popover: gtk::PopoverMenu, - actions: CellActions, -} - -#[derive(Clone, Copy)] -struct MenuShape { - edit_cell: bool, - set_value: bool, - row_ops: bool, -} - -impl MenuShape { - const READ_ONLY: MenuShape = MenuShape { - edit_cell: false, - set_value: false, - row_ops: false, - }; - const ROW_OPS: MenuShape = MenuShape { - edit_cell: false, - set_value: false, - row_ops: true, - }; - const FULL: MenuShape = MenuShape { - edit_cell: true, - set_value: true, - row_ops: true, - }; -} - -fn build_cell_menu(shape: MenuShape) -> gio::Menu { - let menu = gio::Menu::new(); - if shape.edit_cell { - let edit_section = gio::Menu::new(); - let edit_item = gio::MenuItem::new(Some(&crate::i18n::gettext("Edit cell")), Some("cell.edit")); - edit_item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); - edit_section.append_item(&edit_item); - menu.append_section(None, &edit_section); - } - - let copy_as = gio::Menu::new(); - copy_as.append(Some(&crate::i18n::gettext("Rows")), Some("cell.copy-rows")); - copy_as.append( - Some(&crate::i18n::gettext("With Headers")), - Some("cell.copy-rows-headers"), - ); - copy_as.append(Some(&crate::i18n::gettext("JSON")), Some("cell.copy-json")); - copy_as.append(Some(&crate::i18n::gettext("CSV")), Some("cell.copy-csv")); - copy_as.append( - Some(&crate::i18n::gettext("CSV with Headers")), - Some("cell.copy-csv-headers"), - ); - copy_as.append(Some(&crate::i18n::gettext("Markdown")), Some("cell.copy-markdown")); - copy_as.append(Some(&crate::i18n::gettext("IN Clause")), Some("cell.copy-in-clause")); - if shape.row_ops { - let sql_section = gio::Menu::new(); - sql_section.append( - Some(&crate::i18n::gettext("INSERT Statement")), - Some("cell.copy-row-insert"), - ); - copy_as.append_section(None, &sql_section); - } - let copy_section = gio::Menu::new(); - copy_section.append(Some(&crate::i18n::gettext("Copy")), Some("cell.copy")); - copy_section.append_submenu(Some(&crate::i18n::gettext("Copy as")), ©_as); - copy_section.append( - Some(&crate::i18n::gettext("Copy column name")), - Some("cell.copy-column-name"), - ); - menu.append_section(None, ©_section); - - let json_section = gio::Menu::new(); - json_section.append( - Some(&crate::i18n::gettext("Show Row as JSON")), - Some("cell.show-row-json"), - ); - menu.append_section(None, &json_section); - - let action_section = gio::Menu::new(); - if shape.set_value { - let set_value = gio::Menu::new(); - // Only a free-text column can hold an empty string: elsewhere - // "Empty" would either be rejected by the server or mean NULL, - // which the item below already says plainly. - let empty_item = gio::MenuItem::new(Some(&crate::i18n::gettext("Empty")), Some("cell.set-empty")); - empty_item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); - set_value.append_item(&empty_item); - set_value.append(Some("NULL"), Some("cell.set-null")); - action_section.append_submenu(Some(&crate::i18n::gettext("Set Value")), &set_value); - } - action_section.append(Some(&crate::i18n::gettext("Export Results…")), Some("cell.export")); - if shape.row_ops { - action_section.append(Some(&crate::i18n::gettext("Insert row")), Some("cell.insert-row")); - action_section.append(Some(&crate::i18n::gettext("Duplicate")), Some("cell.duplicate-row")); - action_section.append(Some(&crate::i18n::gettext("Delete")), Some("cell.delete-row")); - } - menu.append_section(None, &action_section); - menu -} - -/// Everything the shared menu needs, named rather than positional. -struct GridMenuInit<'a> { - column_view: &'a gtk::ColumnView, - sender: relm4::Sender, - columns: std::sync::Arc<[ResultColumn]>, - /// Carried from the fetch that produced this grid so the menu's - /// Export Results reports the same truncation the paginator does. - truncated: bool, - tab_ctx: TabGridContext, - editable: bool, -} - -fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { - let GridMenuInit { - column_view, - sender, - columns, - truncated, - tab_ctx, - editable, - } = init; - let context: Rc>> = Rc::new(RefCell::new(None)); - // Every action closure holds the view weakly. The action group - // belongs to the ColumnView, so a strong clone in a closure is a - // cycle: the grid, its selection model, its store and every row it - // holds would outlive the page that built them. - let view = column_view.downgrade(); - let tab = Rc::new(tab_ctx); - - let group = gio::SimpleActionGroup::new(); - let slot_position = |slot: &CellContext| POSITION_SLOT.get(&slot.widget).unwrap_or(0); - - macro_rules! cell_action { - ($name:literal, |$slot:ident| $body:expr) => {{ - let ctx = context.clone(); - gio::ActionEntry::builder($name) - .activate(move |_, _, _| { - let clicked = ctx.borrow().as_ref().and_then(ClickedCell::get); - if let Some($slot) = clicked.as_ref() { - $body; - } - }) - .build() - }}; - } - // Renders the rows the menu targets, with the change tracker's - // pending edits applied, and puts the result on the clipboard. - fn clipboard_message(encoded: Result) -> GridMsg { - match encoded { - Ok(text) => GridMsg::CopyToClipboard(text), - Err(error) => GridMsg::ShowToast(crate::i18n::gettext_f( - "Couldn't copy the selection: {error}", - &[("error", &error.to_string())], - )), - } - } - macro_rules! copy_action { - ($name:literal, |$slot:ident, $rows:ident| $message:expr) => {{ - let s = sender.clone(); - let view = view.clone(); - let tab = tab.clone(); - cell_action!($name, |$slot| { - let Some(cv) = view.upgrade() else { return }; - let $rows = rows_for_menu(&cv, &tab, slot_position($slot)); - s.send($message).ok(); - }) - }}; - } - macro_rules! send_action { - ($name:literal, |$slot:ident| $msg:expr) => {{ - let s = sender.clone(); - cell_action!($name, |$slot| s.send($msg).ok()) - }}; - } - - let edit_action = cell_action!("edit", |slot| { - if let Ok(label) = slot.widget.clone().downcast::() { - enter_edit_mode(&label); - } - }); - // Plain Copy reads the cell's value, never the widget's text: the - // widget carries the display form, which is the `` sentinel, - // the `(auto)` placeholder, `` for a blob and a value cut - // at the 10k display cap. - let copy_action = { - let s = sender.clone(); - let view = view.clone(); - let tab = tab.clone(); - let cols = columns.clone(); - cell_action!("copy", |slot| { - let Some(cv) = view.upgrade() else { return }; - let rows = rows_for_menu(&cv, &tab, slot_position(slot)); - let message = match rows.as_slice() { - [row] => GridMsg::CopyToClipboard( - row.get(slot.col_index) - .and_then(|value| tablepro_core::export::cell_text(&cols, slot.col_index, value)) - .unwrap_or_default(), - ), - many => clipboard_message(tablepro_core::export::render_tsv(&cols, many, false)), - }; - s.send(message).ok(); - }) - }; - let copy_rows_action = { - let cols = columns.clone(); - copy_action!("copy-rows", |_slot, rows| clipboard_message( - tablepro_core::export::render_tsv(&cols, &rows, false) - )) - }; - let copy_rows_headers_action = { - let cols = columns.clone(); - copy_action!("copy-rows-headers", |_slot, rows| clipboard_message( - tablepro_core::export::render_tsv(&cols, &rows, true) - )) - }; - let copy_json_action = { - let cols = columns.clone(); - copy_action!("copy-json", |_slot, rows| GridMsg::CopyToClipboard( - tablepro_core::export::render_json(&cols, &rows) - )) - }; - let copy_csv_action = { - let cols = columns.clone(); - copy_action!("copy-csv", |_slot, rows| clipboard_message( - tablepro_core::export::render_csv( - &cols, - &rows, - &tablepro_core::export::CsvOptions { - header_row: false, - ..Default::default() - } - ) - )) - }; - let copy_csv_headers_action = { - let cols = columns.clone(); - copy_action!("copy-csv-headers", |_slot, rows| clipboard_message( - tablepro_core::export::render_csv(&cols, &rows, &tablepro_core::export::CsvOptions::default()) - )) - }; - let copy_markdown_action = { - let cols = columns.clone(); - copy_action!("copy-markdown", |_slot, rows| GridMsg::CopyToClipboard( - tablepro_core::export::render_markdown(&cols, &rows) - )) - }; - // NULL and binary values have no place in an IN list, so the - // clause says how many it left out instead of handing back a - // shorter list that quietly selects different rows. - let copy_in_clause_action = { - let s = sender.clone(); - let view = view.clone(); - let tab = tab.clone(); - cell_action!("copy-in-clause", |slot| { - let Some(cv) = view.upgrade() else { return }; - let rows = rows_for_menu(&cv, &tab, slot_position(slot)); - let clause = tablepro_core::export::render_in_clause(&rows, slot.col_index); - if clause.sql.is_empty() { - s.send(GridMsg::ShowToast(crate::i18n::gettext( - "Nothing to copy: an IN clause can't carry NULL or binary values", - ))) - .ok(); - return; - } - s.send(GridMsg::CopyToClipboard(clause.sql)).ok(); - if clause.skipped > 0 { - s.send(GridMsg::ShowToast(crate::i18n::gettext_f( - "{n} NULL or binary values left out of the IN clause", - &[("n", &clause.skipped.to_string())], - ))) - .ok(); - } - }) - }; - let copy_column_name_action = send_action!("copy-column-name", |slot| GridMsg::CopyToClipboard( - slot.column_name.clone() - )); - let copy_row_insert_action = send_action!("copy-row-insert", |slot| GridMsg::CopyRowAsInsert { - row_position: slot_position(slot), - }); - let show_row_json_action = { - let cols = columns.clone(); - let view = view.clone(); - let tab = tab.clone(); - cell_action!("show-row-json", |slot| { - let Some(cv) = view.upgrade() else { return }; - let Some(row) = row_at(&cv, slot_position(slot)) else { - return; - }; - let text = tablepro_core::export::row_to_json(&cols, &tab.effective_cells(&row)); - show_row_json_dialog(&cv, text); - }) - }; - let set_empty_action = send_action!("set-empty", |slot| GridMsg::SetCellValue { - row_position: slot_position(slot), - col_index: slot.col_index, - preset: CellPreset::Empty, - }); - let set_null_action = send_action!("set-null", |slot| GridMsg::SetCellValue { - row_position: slot_position(slot), - col_index: slot.col_index, - preset: CellPreset::Null, - }); - let export_action = { - let s = sender.clone(); - let view = view.clone(); - let cols = columns.clone(); - let tab = tab.clone(); - gio::ActionEntry::builder("export") - .activate(move |_, _, _| { - let Some(cv) = view.upgrade() else { return }; - s.send(GridMsg::ExportResults(export_snapshot(&cv, &cols, truncated, &tab))) - .ok(); - }) - .build() - }; - let insert_row_action = { - let s = sender.clone(); - gio::ActionEntry::builder("insert-row") - .activate(move |_, _, _| { - s.send(GridMsg::InsertRow).ok(); - }) - .build() - }; - let delete_row_action = send_action!("delete-row", |slot| GridMsg::DeleteRowAt { - row_position: slot_position(slot), - }); - let duplicate_row_action = send_action!("duplicate-row", |slot| GridMsg::DuplicateRow { - row_position: slot_position(slot), - }); - - let mut entries = vec![ - copy_action, - copy_rows_action, - copy_rows_headers_action, - copy_json_action, - copy_csv_action, - copy_csv_headers_action, - copy_markdown_action, - copy_in_clause_action, - copy_column_name_action, - show_row_json_action, - export_action, - ]; - // A read-only grid registers none of the mutating actions: it - // shows no menu item for them, and the editor throws their - // messages away at the far end. - if editable { - entries.extend([ - edit_action, - set_empty_action, - set_null_action, - copy_row_insert_action, - insert_row_action, - delete_row_action, - duplicate_row_action, - ]); - } - group.add_action_entries(entries); - column_view.insert_action_group("cell", Some(&group)); - - let actions = CellActions { - edit: editable.then(|| simple_action(&group, "edit")).flatten(), - set_empty: editable.then(|| simple_action(&group, "set-empty")).flatten(), - }; - - // Popovers are parented eagerly so each PopoverMenu's action muxer - // snapshots the ColumnView's `cell` group at set_parent() time. - // Lazy parenting in the gesture handler drops every activation - // silently (see sidebar_row.rs for the same root cause). - let make_popover = |shape: MenuShape| { - let popover = gtk::PopoverMenu::from_model_full(&build_cell_menu(shape), gtk::PopoverMenuFlags::NESTED); - popover.set_has_arrow(true); - popover.set_parent(column_view); - popover - }; - // One popover per shape the grid can actually show. A read-only - // grid has a single shape and both fields name it. - let (editable_popover, readonly_popover) = if editable { - (make_popover(MenuShape::FULL), make_popover(MenuShape::ROW_OPS)) - } else { - let readonly = make_popover(MenuShape::READ_ONLY); - (readonly.clone(), readonly) - }; - - let mut popovers_for_destroy = vec![editable_popover.clone()]; - if readonly_popover != editable_popover { - popovers_for_destroy.push(readonly_popover.clone()); - } - - if editable { - let empty_menu = gio::Menu::new(); - empty_menu.append(Some(&crate::i18n::gettext("Insert row")), Some("cell.insert-row")); - let empty_popover = gtk::PopoverMenu::from_model_full(&empty_menu, gtk::PopoverMenuFlags::NESTED); - empty_popover.set_has_arrow(true); - empty_popover.set_parent(column_view); - popovers_for_destroy.push(empty_popover.clone()); - - let view_for_empty = view.clone(); - let empty_gesture = gtk::GestureClick::builder().button(3).build(); - empty_gesture.connect_pressed(move |g, _, x, y| { - let Some(cv) = view_for_empty.upgrade() else { return }; - let cv_widget: gtk::Widget = cv.clone().upcast(); - if let Some(picked) = cv.pick(x, y, gtk::PickFlags::DEFAULT) - && picked != cv_widget - { - return; - } - g.set_state(gtk::EventSequenceState::Claimed); - empty_popover.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); - empty_popover.popup(); - }); - column_view.add_controller(empty_gesture); - } - - column_view.connect_destroy(move |_| { - for popover in &popovers_for_destroy { - popover.unparent(); - } - }); - - GridMenus { - context, - editable_popover, - readonly_popover, - actions, - } -} - -fn simple_action(group: &gio::SimpleActionGroup, name: &str) -> Option { - group - .lookup_action(name) - .and_then(|a| a.downcast::().ok()) -} - -pub(super) fn selected_positions(selection: >k::MultiSelection) -> Vec { - let bitset = selection.selection(); - let mut out = Vec::with_capacity(bitset.size() as usize); - for i in 0..bitset.size() { - out.push(bitset.nth(i as u32)); - } - out.sort_unstable(); - out -} - -fn row_at(column_view: >k::ColumnView, position: u32) -> Option { - column_view.model()?.item(position)?.downcast::().ok() -} - -fn selection_of(column_view: >k::ColumnView) -> Option { - column_view.model()?.downcast::().ok() -} - -/// Right-click acts on the row under the pointer. A row already in the -/// selection leaves the selection alone, so a right-click inside a -/// multi-row block still copies the block; any other row becomes the -/// selection. That is what every native list does, and it is what -/// keeps Copy and Delete in one menu pointing at the same rows. -fn select_row_for_menu(column_view: >k::ColumnView, position: u32) { - let Some(selection) = selection_of(column_view) else { - return; - }; - if position >= selection.n_items() || selection.is_selected(position) { - return; - } - selection.select_item(position, true); -} - -/// The rows a menu action applies to, with the change tracker's -/// pending edits applied. `clicked` is the fallback for the keyboard -/// path on a grid whose selection is empty. -fn rows_for_menu(column_view: >k::ColumnView, ctx: &TabGridContext, clicked: u32) -> Vec> { - let mut positions = selection_of(column_view) - .map(|s| selected_positions(&s)) - .unwrap_or_default(); - if positions.is_empty() { - positions.push(clicked); - } - positions - .iter() - .filter_map(|p| row_at(column_view, *p)) - .map(|r| ctx.effective_cells(&r)) - .collect() -} - -/// The page as the grid is showing it: every row in the model with the -/// tracker's pending edits applied, and the truncation flag of the -/// fetch that filled it. The paginator's export button and the context -/// menu's Export Results both build their payload here, so one menu -/// label cannot mean two different files. -pub(super) fn export_snapshot( - column_view: >k::ColumnView, - columns: &[ResultColumn], - truncated: bool, - ctx: &TabGridContext, -) -> QueryResult { - let rows = match column_view.model() { - Some(model) => (0..model.n_items()) - .filter_map(|p| row_at(column_view, p)) - .map(|r| ctx.effective_cells(&r)) - .collect(), - None => Vec::new(), - }; - QueryResult::new(columns.to_vec(), rows).truncated(truncated) -} - -fn show_row_json_dialog(parent: &impl IsA, json: String) { - let buffer = sourceview5::Buffer::new(None); - if let Some(lang) = sourceview5::LanguageManager::default().language("json") { - buffer.set_language(Some(&lang)); - } - let scheme_name = if adw::StyleManager::default().is_dark() { - "Adwaita-dark" - } else { - "Adwaita" - }; - buffer.set_style_scheme(sourceview5::StyleSchemeManager::default().scheme(scheme_name).as_ref()); - buffer.set_text(&json); - let view = sourceview5::View::with_buffer(&buffer); - view.set_editable(false); - view.set_monospace(true); - view.set_show_line_numbers(true); - view.set_top_margin(8); - view.set_left_margin(8); - let scrolled = gtk::ScrolledWindow::builder().child(&view).vexpand(true).build(); - - let copy_button = gtk::Button::from_icon_name(crate::ui::icons::EDIT_COPY); - copy_button.set_tooltip_text(Some(&crate::i18n::gettext("Copy"))); - copy_button.connect_clicked(move |b| b.clipboard().set_text(&json)); - let header = adw::HeaderBar::new(); - header.pack_end(©_button); - let toolbar = adw::ToolbarView::new(); - toolbar.add_top_bar(&header); - toolbar.set_content(Some(&scrolled)); - - adw::Dialog::builder() - .title(crate::i18n::gettext("Row as JSON")) - .content_width(560) - .content_height(480) - .child(&toolbar) - .build() - .present(Some(parent)); -} - -/// Wire the right-click + Menu-key gestures on a single cell widget -/// against the shared `GridMenus`. The cell stores its identity -/// (widget, idx, column_name, is_text_editable) into the shared -/// context slot at press time, then pops up the appropriate popover. -/// No popover or action group is constructed per cell — those live -/// once at the ColumnView level. -fn attach_cell_gesture( - widget: >k::Widget, - column_view: &glib::WeakRef, - target: CellMenuTarget, - menus: &GridMenus, -) { - let popover = if target.editable { - menus.editable_popover.clone() - } else { - menus.readonly_popover.clone() - }; - - // Fill the shared context slot, put the clicked row in the - // selection and arm the per-cell actions. Both entry paths (right - // click, Menu key) do exactly this before showing the popover. - let prepare = { - let context = menus.context.clone(); - let actions = menus.actions.clone(); - let target = target.clone(); - move |widget: >k::Widget, column_view: >k::ColumnView| { - *context.borrow_mut() = Some(ClickedCell { - widget: widget.downgrade(), - col_index: target.col_index, - column_name: target.column_name.clone(), - }); - actions.arm(&target); - if let Some(position) = POSITION_SLOT.get(widget) { - select_row_for_menu(column_view, position); - } - } - }; - - // The handlers below read the cell from their own arguments: the - // cell owns its controllers, so capturing it would be a cycle. - let view_for_gesture = column_view.clone(); - let popover_for_gesture = popover.clone(); - let prepare_for_gesture = prepare.clone(); - let gesture = gtk::GestureClick::new(); - gesture.set_button(3); - gesture.connect_pressed(move |g, _, x, y| { - let (Some(cv), Some(cell)) = (view_for_gesture.upgrade(), g.widget()) else { - return; - }; - g.set_state(gtk::EventSequenceState::Claimed); - prepare_for_gesture(&cell, &cv); - // Translate the click point into the ColumnView's coordinate - // space — the popover is parented to the ColumnView so - // pointing_to is interpreted there, not in cell-local coords. - // `compute_point` is the GTK 4.12+ replacement for the - // deprecated `translate_coordinates`. - let local = gtk::graphene::Point::new(x as f32, y as f32); - let (cv_x, cv_y) = cell - .compute_point(&cv, &local) - .map(|p| (p.x() as i32, p.y() as i32)) - .unwrap_or((x as i32, y as i32)); - popover_for_gesture.set_pointing_to(Some(>k::gdk::Rectangle::new(cv_x, cv_y, 1, 1))); - popover_for_gesture.popup(); - }); - widget.add_controller(gesture); - - let view_for_key = column_view.clone(); - let popover_for_key = popover; - let menu_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::Menu, - gtk::gdk::ModifierType::empty(), - )) - .action(>k::CallbackAction::new(move |cell, _| { - let Some(cv) = view_for_key.upgrade() else { - return glib::Propagation::Proceed; - }; - prepare(cell, &cv); - // Anchor on the cell's full bounds so the popover lands - // visually under the cell rather than at an arbitrary - // mouse-position-of-last-click. - if let Some(bounds) = cell.compute_bounds(&cv) { - let rect = gtk::gdk::Rectangle::new( - bounds.x() as i32, - bounds.y() as i32, - bounds.width() as i32, - bounds.height() as i32, - ); - popover_for_key.set_pointing_to(Some(&rect)); - } else { - popover_for_key.set_pointing_to(None); - } - popover_for_key.popup(); - glib::Propagation::Stop - })) - .build(); - let shortcut_controller = gtk::ShortcutController::new(); - shortcut_controller.add_shortcut(menu_shortcut); - widget.add_controller(shortcut_controller); -} - -fn is_cell_editable(col: &ColumnInfo) -> bool { - // Primary keys: locked because the grid identifies rows by PK and - // editing a PK component would orphan the tracker's row identity. - // Generated columns: the database computes them from other columns; - // a user-supplied value would be rejected at commit. - // Auto-increment non-PK: rare but possible; same rejection at commit. - // Bytes / blobs: not text-editable in any meaningful way. - !col.primary_key && !col.is_generated && !col.is_auto_increment && !is_bytes_kind(&col.column_type.kind) -} - -/// A blob has no text form to type into, so the grid never offers one. -fn is_bytes_kind(kind: &tablepro_core::column::ColumnKind) -> bool { - matches!(kind, tablepro_core::column::ColumnKind::Bytes { .. }) -} - -#[derive(Debug)] -struct EditSnapshot { - position: u32, - original: String, -} - -struct WidgetSlot { - key: &'static str, - _phantom: std::marker::PhantomData, -} - -impl WidgetSlot { - const fn new(key: &'static str) -> Self { - Self { - key, - _phantom: std::marker::PhantomData, - } - } - - fn set(&self, widget: &impl IsA, value: T) { - unsafe { widget.set_data(self.key, value) }; - } - - fn take(&self, widget: &impl IsA) -> Option { - unsafe { widget.steal_data::(self.key) } - } -} - -impl WidgetSlot { - fn get(&self, widget: &impl IsA) -> Option { - unsafe { widget.data::(self.key).map(|p| *p.as_ref()) } - } -} - -const POSITION_SLOT: WidgetSlot = WidgetSlot::new("tp-position"); -const SNAPSHOT_SLOT: WidgetSlot = WidgetSlot::new("tp-snapshot"); -/// Column index of an editable cell. Set at setup time; read by the -/// "Set to NULL" keyboard shortcut (Ctrl+Shift+N) which routes via the -/// app-level focused-widget lookup and needs `(row, col)` to emit -/// `GridMsg::SetCellNull`. -const COLUMN_SLOT: WidgetSlot = WidgetSlot::new("tp-column"); -/// Suppression flag for `GtkCheckButton::toggled` during programmatic -/// `set_active()` calls in the bind callback. Without this the bind -/// would echo as a synthetic `CellEdited` event and clobber the -/// tracker with the freshly-rendered value, looping forever. -const SUPPRESS_SLOT: WidgetSlot = WidgetSlot::new("tp-suppress-toggle"); -/// Currently-open popover (calendar / spin button / JSON editor) -/// anchored to a cell widget. Set by `show_*_popover` helpers; cleared -/// by `connect_closed`. The factory's `connect_unbind` takes this slot -/// and calls `popdown()` so the popover unparents itself before its -/// parent cell widget is recycled. Without this guard ColumnView's -/// cell pool can drop a parent that still has an open popover child, -/// producing the same "Finalizing widget, but it still has children -/// left: GtkPopover" warning that we hit on context menus. -const POPOVER_SLOT: WidgetSlot = WidgetSlot::new("tp-popover"); -/// `true` while the cell's inner GtkText has a non-empty IME preedit -/// (CJK / Korean / Vietnamese / etc. composition in flight). The -/// commit handler reads this flag and defers `editing-notify(false)` -/// commits while preedit is active — focus shifts to an IME popover -/// fire `editing-notify(false)` mid-composition, and committing the -/// raw text at that point would either send an empty value or the -/// pre-composition snapshot. Cleared by the preedit-changed handler -/// when the user finishes composing. -const PREEDIT_SLOT: WidgetSlot = WidgetSlot::new("tp-preedit-active"); - -/// Look up the `(row_position, col_index)` of the currently focused -/// cell widget. Returns `None` when the focus is outside the window -/// or on a widget without the per-cell slots set. Widget-type- -/// agnostic by design: works for `super::cell_editor::CellEditor` (text cells), -/// `gtk::CheckButton` (bool cells), and any future cell-widget that -/// writes the slots at setup time. -pub(crate) fn focused_cell_coords(widget: &impl IsA) -> Option<(u32, usize)> { - let root = widget.root()?; - let window = root.dynamic_cast::().ok()?; - // Disambiguate `focus()` which exists on both GtkWindowExt (returns - // the focused widget inside the window) and RootExt (returns the - // window itself); we want the former. - let focused = gtk4::prelude::GtkWindowExt::focus(&window)?; - let position = POSITION_SLOT.get(&focused)?; - let column = COLUMN_SLOT.get(&focused)?; - Some((position, column)) -} - -/// Maximum characters rendered into a single grid cell. A million- -/// character TEXT or JSON column would otherwise hang the GTK main -/// thread inside Pango layout. The full value stays in the model -/// (and the JSON popover renders the unabridged text via -/// SourceView), so no editable data is lost — only the in-grid -/// preview is capped. -const DISPLAY_TEXT_MAX_CHARS: usize = 10_000; -/// Quick byte-length pre-check that avoids an O(n) `chars().count()` -/// call on safely-short strings. Worst-case UTF-8 is 4 bytes/char. -const DISPLAY_TEXT_BYTES_THRESHOLD: usize = DISPLAY_TEXT_MAX_CHARS * 4; - -pub fn value_to_display_text(value: &Value) -> String { - match value { - Value::Null => readonly_null_sentinel(), - // A blob has no readable text form, so the grid says how big - // it is rather than showing bytes. - Value::Bytes(b) => format!("<{} bytes>", b.len()), - // The driver could not read this, and saying so is better than - // an empty cell that looks like a NULL. - Value::Undecodable(undecodable) => format!("", undecodable.type_name), - // Everything else renders the way it exports, so what the grid - // shows and what a copy produces are the same text. - other => match tablepro_core::export::value_to_text(other) { - Some(text) => truncate_for_display(&text), - None => readonly_null_sentinel(), - }, - } -} - -pub fn value_to_edit_text(value: &Value) -> String { - match value { - Value::Null => String::new(), - other => value_to_display_text(other), - } -} - -/// Cap a string at `DISPLAY_TEXT_MAX_CHARS` for display purposes. -/// Short strings pass through unchanged (no allocation). Long strings -/// are truncated at a UTF-8 char boundary with a `… (+N more chars)` -/// suffix so the user knows there's content beyond what's shown. -fn truncate_for_display(s: &str) -> String { - if s.len() < DISPLAY_TEXT_BYTES_THRESHOLD { - return s.to_string(); - } - // Find the byte index of the (DISPLAY_TEXT_MAX_CHARS+1)-th char so - // we slice on a valid UTF-8 boundary. - let mut cut = s.len(); - for (i, (byte_idx, _)) in s.char_indices().enumerate() { - if i >= DISPLAY_TEXT_MAX_CHARS { - cut = byte_idx; - break; - } - } - if cut >= s.len() { - return s.to_string(); - } - let head = &s[..cut]; - let remaining = s[cut..].chars().count(); - format!("{head}… (+{remaining} more chars)") -} - -#[cfg(test)] -mod tests { - #[gtk4::test] - fn calendar_selects_cell_date() { - let calendar = gtk::Calendar::new(); - let date = glib::DateTime::from_local(2026, 9, 15, 0, 0, 0.0).unwrap(); - - calendar.set_date(&date); - - assert_eq!(calendar.date().year(), 2026); - assert_eq!(calendar.date().month(), 9); - assert_eq!(calendar.date().day_of_month(), 15); - } - - use super::*; - - #[expect( - clippy::disallowed_methods, - reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" - )] - fn col(data_type: &str, primary_key: bool) -> ColumnInfo { - let kind = tablepro_core::column::classify_type_name(data_type); - ColumnInfo { - name: "x".into(), - column_type: tablepro_core::column::ColumnType { - name: data_type.to_owned(), - dynamic_storage: tablepro_core::column::has_dynamic_storage(&kind), - kind, - catalog: Some(tablepro_core::column::CatalogType { - declaration: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), - cast_target: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), - }), - read_form: tablepro_core::column::ReadForm::Native, - }, - nullable: true, - primary_key, - is_auto_increment: false, - is_generated: false, - default: tablepro_core::column::ColumnDefault::None, - comment: None, - } - } - - fn cell_texts(root: &impl IsA) -> Vec { - crate::test_support::descendants(root) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .map(|cell| cell.text().to_string()) - .collect() - } - - #[gtk4::test] - fn edit_during_rebind_shows_pending_value() { - use crate::services::tracker_edit::TrackerEdit; - - let mut id = col("integer", true); - id.name = "id".into(); - let mut name = col("text", false); - name.name = "name".into(); - let columns = vec![id, name]; - let result = QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - vec![vec![Value::Int(1), Value::Text("old".into())]], - ); - let tracker = TabTrackerHandle::default(); - let (sender, _receiver) = relm4::channel::(); - let context = TabGridContext { - tracker: Some(tracker.clone()), - pk_col_indices: vec![0], - column_widths: None, - }; - let (view, selection) = build_column_view(&result, &columns, sender, true, None, None, context); - let window = gtk::Window::new(); - window.set_child(Some(&view)); - window.present(); - crate::test_support::wait_until(std::time::Duration::from_secs(5), || { - cell_texts(&view).iter().any(|text| text == "old") - }) - .expect("the fetched value is drawn"); - - let delta = tracker.apply(TrackerEdit::Cell { - row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), - col: 1, - original: Value::Text("old".into()), - value: Value::Text("new".into()), - }); - // A fresh row object, because the list item manager skips the - // rebind for an items-changed that keeps the same item. - let store = selection - .model() - .and_downcast::() - .expect("a list store"); - let row = store.item(0).and_downcast::().expect("a row"); - store.splice(0, 1, &[RowObject::new(row.cells_clone())]); - - crate::test_support::wait_until(std::time::Duration::from_secs(5), || { - cell_texts(&view).iter().any(|text| text == "new") - }) - .expect("the rebind draws the pending value"); - assert_eq!(delta.pending_count, 1); - window.close(); - crate::test_support::drain_main_context(); - } - - fn width_binding(tasks: &tablepro_session::runtime::Tasks, root: &tempfile::TempDir) -> ColumnWidthBinding { - ColumnWidthBinding { - store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), tasks), - connection_id: uuid::Uuid::new_v4(), - schema: None, - table: "t".to_owned(), - } - } - - #[gtk4::test] - fn column_freed_with_column_view() { - let runtime = crate::test_support::test_runtime(); - let root = tempfile::tempdir().expect("tempdir"); - let mut name = col("text", false); - name.name = "name".into(); - let columns = vec![name]; - let result = QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - vec![vec![Value::Text("a".into())]], - ); - let (sender, _receiver) = relm4::channel::(); - let context = TabGridContext { - tracker: None, - pk_col_indices: Vec::new(), - column_widths: Some(width_binding(&runtime.tasks(), &root)), - }; - let (view, selection) = build_column_view( - &result, - &columns, - sender.clone(), - true, - Some((0, true)), - Some(sender), - context, - ); - let column = view - .columns() - .item(0) - .and_downcast::() - .expect("a column") - .downgrade(); - let view_ref = view.downgrade(); - - drop((view, selection)); - crate::test_support::drain_main_context(); - - assert!(column.upgrade().is_none(), "the column outlived its column view"); - assert!(view_ref.upgrade().is_none(), "the column view outlived its owner"); - } - - fn menu_gesture(cell: >k::Widget) -> Option { - let controllers = cell.observe_controllers(); - (0..controllers.n_items()) - .filter_map(|index| controllers.item(index).and_downcast::()) - .find(|gesture| gesture.button() == 3) - } - - #[gtk4::test] - fn cells_freed_with_column_view() { - let mut id = col("integer", true); - id.name = "id".into(); - let mut name = col("text", false); - name.name = "name".into(); - let mut flag = col("boolean", false); - flag.name = "flag".into(); - let columns = vec![id, name, flag]; - let result = QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - vec![vec![Value::Int(7), Value::Text("a".into()), Value::Bool(true)]], - ); - let (sender, _receiver) = relm4::channel::(); - let context = TabGridContext { - tracker: Some(TabTrackerHandle::default()), - pk_col_indices: vec![0], - column_widths: None, - }; - let (view, selection) = build_column_view(&result, &columns, sender, true, None, None, context); - let window = gtk::Window::new(); - window.set_child(Some(&view)); - window.present(); - let cells = || -> Vec { - crate::test_support::descendants(&view) - .into_iter() - .filter(|widget| { - widget.is::() - || widget.is::() - || widget - .downcast_ref::() - .is_some_and(|label| label.text() == "7") - }) - .collect() - }; - crate::test_support::wait_until(std::time::Duration::from_secs(5), || cells().len() == 3) - .expect("the read-only, text and bool cells are drawn"); - // A right-click is what fills the menu's target, so it has to be - // in the picture too. - for cell in cells() { - let gesture = menu_gesture(&cell).expect("a cell answers a right-click"); - gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); - } - let freed: Vec> = cells().iter().map(ObjectExt::downgrade).collect(); - - window.destroy(); - drop(window); - drop((view, selection)); - crate::test_support::drain_main_context(); - - let alive = freed.iter().filter(|cell| cell.upgrade().is_some()).count(); - assert_eq!(alive, 0, "cells outlived their column view"); - } - - #[derive(Clone, Copy, Debug)] - enum PopoverEditor { - Date, - Int, - Json, - } - - /// Commit through the editor's own control, which also closes it. - fn commit_popover(editor: PopoverEditor, popover: >k::Popover) { - use crate::test_support::first_descendant_of_type; - match editor { - PopoverEditor::Date => first_descendant_of_type::(popover) - .expect("the calendar") - .emit_by_name::<()>("day-selected", &[]), - PopoverEditor::Int => { - let spin = first_descendant_of_type::(popover).expect("the spin button"); - spin.set_value(42.0); - spin.emit_by_name::<()>("activate", &[]); - } - PopoverEditor::Json => { - first_descendant_of_type::(popover) - .expect("the JSON view") - .buffer() - .set_text("[1]"); - crate::test_support::descendants(popover) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|button| button.has_css_class("suggested-action")) - .expect("the Save button") - .emit_clicked(); - } - } - } - - /// Another test's window can take the focus and close an autohide - /// popover before it opens, so a dismissed one is shown again. - fn open_popover( - editor: PopoverEditor, - cell: &crate::ui::cell_editor::CellEditor, - sender: &relm4::Sender, - ) -> Option { - for _ in 0..5 { - match editor { - PopoverEditor::Date => show_calendar_popover(cell, 0, sender), - PopoverEditor::Int => show_spin_button_popover(cell, 0, sender, false), - PopoverEditor::Json => show_json_popover(cell, 0, sender), - } - let popover = crate::test_support::first_descendant_of_type::(cell)?; - let settled = crate::test_support::wait_until(std::time::Duration::from_secs(5), || { - popover.is_mapped() || !popover.is_visible() - }); - if settled.is_ok() && popover.is_mapped() { - return Some(popover); - } - if popover.parent().is_some() { - popover.unparent(); - } - POPOVER_SLOT.take(cell); - } - None - } - - #[gtk4::test] - fn a_closed_popover_editor_frees_its_cell() { - use crate::test_support::wait_until; - let timeout = std::time::Duration::from_secs(5); - let window = gtk::Window::new(); - window.present(); - for editor in [PopoverEditor::Date, PopoverEditor::Int, PopoverEditor::Json] { - let (sender, _receiver) = relm4::channel::(); - let cell = crate::ui::cell_editor::CellEditor::new(); - cell.set_text("x"); - window.set_child(Some(&cell)); - wait_until(timeout, || cell.is_mapped()).expect("the cell is drawn"); - let popover = open_popover(editor, &cell, &sender).expect("the editor popover opens"); - - commit_popover(editor, &popover); - wait_until(timeout, || popover.parent().is_none()).expect("the popover closes"); - assert_ne!(cell.text(), "x", "{editor:?} did not commit"); - assert!( - POPOVER_SLOT.take(&cell).is_none(), - "{editor:?} left its popover in the cell" - ); - // Only the cell is checked: GTK itself keeps a closed autohide - // popover alive once a child of it has taken focus. - let freed = cell.downgrade(); - window.set_child(None::<>k::Widget>); - drop((cell, popover)); - crate::test_support::drain_main_context(); - - assert!(freed.upgrade().is_none(), "{editor:?} kept its cell alive"); - } - window.destroy(); - } - - #[test] - fn a_tracked_draft_carries_its_insert_tint_and_error_flash() { - let tracker = TabTrackerHandle::default(); - let delta = tracker.apply(crate::services::tracker_edit::TrackerEdit::Insert { - values: vec![Value::Null], - }); - let Some(crate::services::tracker_delta::VisualChange::InsertDraft { draft_id, values }) = - delta.visual.into_iter().next() - else { - panic!("an insert adds a draft row"); - }; - let _ = tracker.flash_error_row(RowKey::Draft(draft_id)); - let context = TabGridContext { - tracker: Some(tracker), - pk_col_indices: vec![0], - column_widths: None, - }; - - let (_, classes) = context.bind_state(&RowObject::new_draft(draft_id, values), 0); - - assert_eq!(classes, vec!["tp-row-pending-insert", "tp-row-leftmost-error-flash"]); - } - - #[test] - fn an_untracked_grid_draws_the_fetched_value_plainly() { - let row = RowObject::new(vec![Value::Int(1)]); - - let (value, classes) = TabGridContext::default().bind_state(&row, 0); - - assert_eq!(value, Value::Int(1)); - assert!(classes.is_empty()); - } - - #[test] - fn editable_for_normal_column() { - assert!(is_cell_editable(&col("text", false))); - assert!(is_cell_editable(&col("integer", false))); - } - - #[test] - fn not_editable_for_primary_key() { - assert!(!is_cell_editable(&col("integer", true))); - } - - #[test] - fn not_editable_for_generated_column() { - let mut c = col("integer", false); - c.is_generated = true; - assert!(!is_cell_editable(&c)); - } - - #[test] - fn not_editable_for_auto_increment_non_pk() { - let mut c = col("integer", false); - c.is_auto_increment = true; - assert!(!is_cell_editable(&c)); - } - - #[test] - fn not_editable_for_bytes() { - assert!(!is_cell_editable(&col("bytea", false))); - assert!(!is_cell_editable(&col("blob", false))); - assert!(!is_cell_editable(&col("longblob", false))); - assert!(!is_cell_editable(&col("BINARY", false))); - assert!(!is_cell_editable(&col("varbinary", false))); - } - - #[test] - fn only_columns_with_an_empty_value_accept_an_empty_string() { - // Clearing a text cell stores the empty string; clearing a - // number or a date can only mean NULL. - for text in ["text", "VARCHAR(255)", "char(3)", "longtext"] { - assert!( - col(text, false).column_type.kind.accepts_empty_string(), - "{text} should accept an empty string" - ); - } - for other in [ - "integer", - "bigint", - "numeric(10,2)", - "date", - "timestamp", - "uuid", - "boolean", - ] { - assert!( - !col(other, false).column_type.kind.accepts_empty_string(), - "{other} should not accept an empty string" - ); - } - } - - #[test] - fn a_blob_is_never_editable_whatever_the_engine_calls_it() { - for name in [ - "BYTEA", - "blob", - "LONGBLOB", - "mediumblob", - "tinyblob", - "VARBINARY", - "binary", - ] { - assert!( - !is_cell_editable(&col(name, false)), - "{name} was offered as an editable cell" - ); - } - assert!(is_cell_editable(&col("text", false))); - assert!(is_cell_editable(&col("integer", false))); - } - - #[test] - fn display_text_primitive_variants() { - assert_eq!(value_to_display_text(&Value::Null), "NULL"); - assert_eq!(value_to_display_text(&Value::Bool(true)), "true"); - assert_eq!(value_to_display_text(&Value::Int(42)), "42"); - assert_eq!(value_to_display_text(&Value::Text("hello".into())), "hello"); - assert_eq!(value_to_display_text(&Value::Bytes(vec![0u8; 16])), "<16 bytes>"); - } - - fn date_value(year: i32, month: u32, day: u32) -> Value { - Value::Date(tablepro_core::value::Temporal::Finite( - chrono::NaiveDate::from_ymd_opt(year, month, day).expect("a date"), - )) - } - - #[test] - fn display_text_temporal_variants() { - let date = chrono::NaiveDate::from_ymd_opt(2026, 4, 26).expect("a date"); - assert_eq!(value_to_display_text(&date_value(2026, 4, 26)), "2026-04-26"); - - let time = chrono::NaiveTime::from_hms_opt(14, 30, 0).expect("a time"); - assert_eq!( - value_to_display_text(&Value::Time(tablepro_core::value::SqlTime::from_time_of_day(time))), - "14:30:00" - ); - - let datetime = chrono::NaiveDateTime::new(date, time); - assert_eq!( - value_to_display_text(&Value::Timestamp(tablepro_core::value::Temporal::Finite(datetime))), - "2026-04-26 14:30:00" - ); - - let tz = chrono::DateTime::::from_naive_utc_and_offset(datetime, chrono::Utc); - assert_eq!( - value_to_display_text(&Value::TimestampTz(tablepro_core::value::Temporal::Finite( - tablepro_core::value::OffsetTimestamp::from_datetime(tz.fixed_offset()) - ))), - "2026-04-26 14:30:00+00:00" - ); - } - - #[test] - fn display_text_extended_variants() { - let dec: tablepro_core::value::SqlDecimal = "1234.56789".parse().expect("a decimal"); - assert_eq!(value_to_display_text(&Value::Decimal(dec)), "1234.56789"); - - let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").expect("a uuid"); - assert_eq!( - value_to_display_text(&Value::Uuid(id)), - "550e8400-e29b-41d4-a716-446655440000" - ); - - let json = tablepro_core::value::JsonText::parse(r#"{"a":1,"b":[2,3]}"#.to_owned()).expect("valid json"); - let text = value_to_display_text(&Value::Json(json)); - assert!(text.contains("\"a\":1"), "{text}"); - } - - #[test] - fn a_value_the_driver_could_not_read_says_so() { - let undecodable = Value::Undecodable(Box::new(tablepro_core::value::UndecodedValue { - type_name: "geography".to_owned(), - reason: tablepro_core::value::UndecodableReason::UnsupportedType, - })); - - let text = value_to_display_text(&undecodable); - - assert!(text.contains("geography"), "{text}"); - assert_ne!(text, "NULL", "an unreadable value looked like a missing one"); - } - - #[test] - fn edit_text_distinguishes_null_from_text_null() { - assert_eq!(value_to_edit_text(&Value::Null), ""); - assert_eq!(value_to_edit_text(&Value::Text("NULL".into())), "NULL"); - assert_eq!(value_to_edit_text(&Value::Int(0)), "0"); - } - - #[test] - fn edit_text_keeps_extended_variants_visible() { - assert_eq!(value_to_edit_text(&date_value(2026, 4, 26)), "2026-04-26"); - - let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); - assert_eq!( - value_to_edit_text(&Value::Uuid(id)), - "550e8400-e29b-41d4-a716-446655440000" - ); - } - - #[test] - fn truncate_short_text_passes_through() { - let s = "hello world"; - assert_eq!(truncate_for_display(s), "hello world"); - } - - #[test] - fn truncate_caps_long_text_at_char_boundary() { - // 100k ASCII chars: should truncate to ~10k + suffix. - let s = "a".repeat(100_000); - let out = truncate_for_display(&s); - assert!(out.starts_with(&"a".repeat(10_000))); - assert!(out.contains("more chars")); - assert!(out.len() < 10_500); // ~10k chars + suffix bytes - } - - #[test] - fn truncate_handles_multibyte_boundary() { - // 30k 4-byte emoji (120k bytes) — truncation must land on a - // char boundary, not mid-codepoint. - let s = "🦀".repeat(30_000); - let out = truncate_for_display(&s); - // Must be valid UTF-8. - assert!(std::str::from_utf8(out.as_bytes()).is_ok()); - // First DISPLAY_TEXT_MAX_CHARS chars should be 🦀 plus the - // suffix appended after. - assert!(out.contains("more chars")); - } - - #[test] - fn display_text_truncates_huge_text_value() { - let huge = "x".repeat(1_000_000); - let display = value_to_display_text(&Value::Text(huge)); - assert!(display.len() < 100_000); // bounded - assert!(display.contains("more chars")); - } -} diff --git a/linux/crates/app/src/ui/cell_editor.rs b/linux/crates/app/src/ui/grid/cell_editor.rs similarity index 64% rename from linux/crates/app/src/ui/cell_editor.rs rename to linux/crates/app/src/ui/grid/cell_editor.rs index f1df895304..c2bebccf3d 100644 --- a/linux/crates/app/src/ui/cell_editor.rs +++ b/linux/crates/app/src/ui/grid/cell_editor.rs @@ -1,14 +1,6 @@ -//! `CellEditor` — the cell widget for editable ColumnView cells. -//! -//! A thin `Stack[Label | Text]` `gtk::Widget` subclass. The display -//! page is a plain `GtkLabel` that does not install any pointer-event -//! controllers, so a single click on the cell bubbles up to the -//! ColumnView's row-selection gesture without a forwarder. The edit -//! page is a `GtkText` shown only after `start_editing()`, where -//! click-to-position-cursor is the desired behaviour. Edit-mode -//! transitions are observable via `connect_editing_notify`, which -//! mirrors the stack's `visible-child-name` notify so callers can -//! snapshot the original text on entry and emit a commit on exit. +//! The display page is a plain label with no pointer controllers, so a +//! single click reaches the column view's row selection. The edit page +//! shows only after `start_editing`. use gtk4::prelude::*; use gtk4::subclass::prelude::*; @@ -40,9 +32,6 @@ mod imp { type ParentType = gtk4::Widget; fn class_init(klass: &mut Self::Class) { - // BinLayout sizes the single child (the stack) to fill - // the cell — same effect as having a single-child - // container. klass.set_layout_manager_type::(); } } @@ -50,8 +39,7 @@ mod imp { impl ObjectImpl for CellEditor { fn constructed(&self) { self.parent_constructed(); - // Stable CSS class so app-level selectors can target this - // widget without depending on the type-name node default. + // App styles select this class, not the type's node name. self.obj().add_css_class("tp-cell-editor"); let stack = &self.stack; stack.set_transition_type(gtk4::StackTransitionType::None); @@ -68,9 +56,6 @@ mod imp { stack.set_visible_child_name("display"); stack.set_parent(&*self.obj()); - // Enter commits the edit. `gtk::Text::activate` fires on - // Enter / Return / KP_Enter — the canonical "user is done - // typing" signal. let weak_for_activate = self.obj().downgrade(); entry.connect_activate(move |_| { if let Some(this) = weak_for_activate.upgrade() { @@ -78,11 +63,8 @@ mod imp { } }); - // Esc cancels — switch back to the display child without - // committing the entry's text. Capture phase so we run - // before the inner GtkText handles the key (otherwise - // GtkText might consume Escape to clear its own buffer - // without us knowing to revert). + // Capture phase, so Escape cancels before the GtkText can + // take it to clear its own buffer. let key_ctrl = gtk4::EventControllerKey::new(); key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); let weak_for_key = self.obj().downgrade(); @@ -98,12 +80,8 @@ mod imp { }); entry.add_controller(key_ctrl); - // Focus-out commits — matches the spreadsheet convention - // where moving focus away accepts the value. Without this - // an edit would only commit on Enter / Tab. GtkText - // flushes any active IME preedit on focus-out before this - // fires, so the entry's text holds the final composed - // value when stop_editing copies it. + // GtkText flushes an IME preedit on focus-out before this + // runs, so the commit copies the composed text. let focus_ctrl = gtk4::EventControllerFocus::new(); let weak_for_focus = self.obj().downgrade(); focus_ctrl.connect_leave(move |_| { @@ -117,9 +95,7 @@ mod imp { } fn dispose(&self) { - // BinLayout-managed child must be unparented before our - // destructor runs or GTK warns about a finalised widget - // with leftover children. + // GTK warns about a finalized widget that still has children. self.stack.unparent(); } } @@ -144,9 +120,6 @@ impl CellEditor { glib::Object::new() } - /// Current displayed text. In edit mode reads from the entry - /// (so a still-editing cell returns the user's in-progress - /// value); otherwise reads from the label. pub fn text(&self) -> glib::GString { if self.is_editing() { self.entry_widget().text() @@ -160,9 +133,7 @@ impl CellEditor { self.entry_widget().set_text(s); } - /// Apply or clear a Pango strikethrough attribute on the display - /// Label. Edit-mode `GtkText` is intentionally unaffected — a row - /// marked for deletion is read-only by definition. + /// The entry is left alone: a row marked for deletion is not edited. pub fn set_strikethrough(&self, on: bool) { let label = self.label_widget(); if on { @@ -184,13 +155,10 @@ impl CellEditor { pub fn start_editing(&self) { let stack = self.stack_widget(); let entry = self.entry_widget(); - // Mirror the label's text into the entry before showing it - // so the user starts editing the value they see. entry.set_text(&self.label_widget().text()); stack.set_visible_child_name("edit"); entry.grab_focus(); - // Select all so the first keystroke replaces — matches the - // GTK Files rename flow and is what spreadsheet users expect. + // The first keystroke replaces the value, as in a Files rename. entry.select_region(0, -1); } @@ -199,15 +167,11 @@ impl CellEditor { let new_text = self.entry_widget().text(); self.label_widget().set_text(&new_text); } else { - // Revert the entry buffer so a subsequent start_editing - // doesn't surface stale aborted text. self.entry_widget().set_text(&self.label_widget().text()); } self.stack_widget().set_visible_child_name("display"); } - /// Subscribe to edit-mode toggles. The callback fires whenever - /// the stack's visible child changes (display ↔ edit). pub fn connect_editing_notify(&self, callback: F) -> glib::SignalHandlerId { let stack = self.stack_widget(); let weak = self.downgrade(); @@ -218,9 +182,6 @@ impl CellEditor { }) } - /// The inner `GtkText` widget. Exposed so callers that need the - /// IME-aware delegate (preedit-changed, etc.) can hook directly - /// onto it. pub fn entry(&self) -> gtk4::Text { self.entry_widget() } diff --git a/linux/crates/app/src/ui/grid/cell_editor_kind.rs b/linux/crates/app/src/ui/grid/cell_editor_kind.rs new file mode 100644 index 0000000000..04c4e70c16 --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_editor_kind.rs @@ -0,0 +1,71 @@ +/// A bool column never gets here: it is a check button, not an editor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum CellEditorKind { + Text, + Date, + Int, + Float, + Json, +} + +pub(super) fn classify_editor_kind(data_type: &str) -> CellEditorKind { + // JSON before the numbers: a contrived type name can match both. + if is_date_type(data_type) { + CellEditorKind::Date + } else if is_json_type(data_type) { + CellEditorKind::Json + } else if is_int_type(data_type) { + CellEditorKind::Int + } else if is_float_type(data_type) { + CellEditorKind::Float + } else { + CellEditorKind::Text + } +} + +pub(super) fn is_bool_type(data_type: &str) -> bool { + let dt = data_type.to_ascii_lowercase(); + matches!(dt.as_str(), "bool" | "boolean" | "bit" | "tinyint(1)") +} + +/// A calendar picks a day and nothing else, so a type with a time in it +/// stays on the text editor. +fn is_date_type(data_type: &str) -> bool { + let dt = data_type.to_ascii_lowercase(); + dt == "date" || (dt.starts_with("date") && !dt.contains("datetime") && !dt.contains("time")) +} + +fn is_int_type(data_type: &str) -> bool { + let dt = data_type.to_ascii_lowercase(); + matches!( + dt.as_str(), + "int" + | "int2" + | "int4" + | "int8" + | "integer" + | "smallint" + | "bigint" + | "tinyint" + | "mediumint" + | "serial" + | "bigserial" + | "smallserial" + ) || dt.starts_with("int(") + || dt.starts_with("integer(") + || dt.starts_with("smallint(") + || dt.starts_with("bigint(") + || dt.starts_with("mediumint(") +} + +/// Decimal, numeric and money are left out because `SqlDecimal` keeps +/// every digit and `GtkSpinButton` holds an f64. +fn is_float_type(data_type: &str) -> bool { + let dt = data_type.to_ascii_lowercase(); + matches!(dt.as_str(), "float" | "double" | "real" | "double precision") || dt.starts_with("float(") +} + +fn is_json_type(data_type: &str) -> bool { + let dt = data_type.to_ascii_lowercase(); + dt.contains("json") +} diff --git a/linux/crates/app/src/ui/grid/cell_popovers.rs b/linux/crates/app/src/ui/grid/cell_popovers.rs new file mode 100644 index 0000000000..ebfa3fc98f --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_popovers.rs @@ -0,0 +1,316 @@ +use chrono::Datelike; +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; +use sourceview5::prelude::*; + +use super::GridMsg; +use super::cell_editor::CellEditor; +use super::widget_slot::{POPOVER_SLOT, POSITION_SLOT}; + +pub(super) fn show_calendar_popover(label: &CellEditor, col_index: usize, sender: &relm4::Sender) { + let calendar = gtk::Calendar::new(); + if let Ok(parsed) = chrono::NaiveDate::parse_from_str(label.text().as_str(), "%Y-%m-%d") + && let Ok(dt) = glib::DateTime::from_local(parsed.year(), parsed.month() as i32, parsed.day() as i32, 0, 0, 0.0) + { + calendar.set_date(&dt); + } + + let popover = gtk::Popover::builder().child(&calendar).build(); + popover.set_parent(label); + POPOVER_SLOT.set(label, popover.clone()); + + let label_for_cal = label.downgrade(); + let popover_for_cal = popover.downgrade(); + let sender_for_cal = sender.clone(); + calendar.connect_day_selected(move |c| { + let (Some(label_for_cal), Some(popover_for_cal)) = (label_for_cal.upgrade(), popover_for_cal.upgrade()) else { + return; + }; + let dt = c.date(); + let formatted = format!("{:04}-{:02}-{:02}", dt.year(), dt.month(), dt.day_of_month()); + let position = POSITION_SLOT.get(&label_for_cal).unwrap_or(0); + // Drawn now rather than after the tracker's round trip. + label_for_cal.set_text(&formatted); + sender_for_cal + .send(GridMsg::CellEdited { + row_position: position, + col_index, + new_value: formatted, + }) + .ok(); + popover_for_cal.popdown(); + }); + + install_popover_close_cleanup(label, &popover); + popover.popup(); +} + +pub(super) fn show_spin_button_popover( + label: &CellEditor, + col_index: usize, + sender: &relm4::Sender, + is_float: bool, +) { + let current_text = label.text().to_string(); + let (initial, lower, upper, step, digits) = if is_float { + let val = current_text.parse::().unwrap_or(0.0); + (val, f64::MIN, f64::MAX, 0.1_f64, 6_u32) + } else { + let val = current_text.parse::().unwrap_or(0) as f64; + (val, i64::MIN as f64, i64::MAX as f64, 1.0_f64, 0_u32) + }; + let adjustment = gtk::Adjustment::new(initial, lower, upper, step, step * 10.0, 0.0); + let spin = gtk::SpinButton::new(Some(&adjustment), step, digits); + spin.set_numeric(true); + spin.set_width_chars(20); + + let popover = gtk::Popover::builder().child(&spin).build(); + popover.set_parent(label); + POPOVER_SLOT.set(label, popover.clone()); + + let label_for_commit = label.downgrade(); + let popover_for_commit = popover.downgrade(); + let sender_for_commit = sender.clone(); + spin.connect_activate(move |s| { + let (Some(label_for_commit), Some(popover_for_commit)) = + (label_for_commit.upgrade(), popover_for_commit.upgrade()) + else { + return; + }; + // f64's Display claims no more precision than the value has. + let formatted = if is_float { + format!("{}", s.value()) + } else { + format!("{}", s.value() as i64) + }; + let position = POSITION_SLOT.get(&label_for_commit).unwrap_or(0); + label_for_commit.set_text(&formatted); + sender_for_commit + .send(GridMsg::CellEdited { + row_position: position, + col_index, + new_value: formatted, + }) + .ok(); + popover_for_commit.popdown(); + }); + + install_popover_close_cleanup(label, &popover); + popover.popup(); + spin.grab_focus(); +} + +/// Saves through a button, since Return inserts a newline in a +/// multi-line buffer. +pub(super) fn show_json_popover(label: &CellEditor, col_index: usize, sender: &relm4::Sender) { + let buffer = sourceview5::Buffer::new(None); + if let Some(lang) = sourceview5::LanguageManager::default().language("json") { + buffer.set_language(Some(&lang)); + } + buffer.set_text(label.text().as_str()); + + let view = sourceview5::View::with_buffer(&buffer); + view.set_show_line_numbers(true); + view.set_monospace(true); + view.set_auto_indent(true); + view.set_tab_width(2); + view.set_indent_width(2); + + let scrolled = gtk::ScrolledWindow::builder() + .child(&view) + .min_content_width(420) + .min_content_height(280) + .has_frame(true) + .build(); + + let save_button = gtk::Button::with_label(&crate::i18n::gettext("Save")); + save_button.add_css_class("suggested-action"); + + let cancel_button = gtk::Button::with_label(&crate::i18n::gettext("Cancel")); + + let button_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .halign(gtk::Align::End) + .build(); + button_box.append(&cancel_button); + button_box.append(&save_button); + + let container = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .margin_top(8) + .margin_bottom(8) + .margin_start(8) + .margin_end(8) + .build(); + container.append(&scrolled); + container.append(&button_box); + + let popover = gtk::Popover::builder().child(&container).build(); + popover.set_parent(label); + POPOVER_SLOT.set(label, popover.clone()); + + let popover_for_cancel = popover.downgrade(); + cancel_button.connect_clicked(move |_| { + if let Some(popover) = popover_for_cancel.upgrade() { + popover.popdown(); + } + }); + + let label_for_commit = label.downgrade(); + let popover_for_commit = popover.downgrade(); + let sender_for_commit = sender.clone(); + let buffer_for_commit = buffer.clone(); + save_button.connect_clicked(move |_| { + let (Some(label_for_commit), Some(popover_for_commit)) = + (label_for_commit.upgrade(), popover_for_commit.upgrade()) + else { + return; + }; + let start = buffer_for_commit.start_iter(); + let end = buffer_for_commit.end_iter(); + let text = buffer_for_commit.text(&start, &end, true).to_string(); + let position = POSITION_SLOT.get(&label_for_commit).unwrap_or(0); + // The tab validates the JSON and refuses a malformed value with + // a toast. + label_for_commit.set_text(&text); + sender_for_commit + .send(GridMsg::CellEdited { + row_position: position, + col_index, + new_value: text, + }) + .ok(); + popover_for_commit.popdown(); + }); + + install_popover_close_cleanup(label, &popover); + popover.popup(); + view.grab_focus(); +} + +fn install_popover_close_cleanup(label: &CellEditor, popover: >k::Popover) { + // The cell holds the popover in its slot and as its child, so the + // popover holds the cell weakly and itself not at all. + let label_for_close = label.downgrade(); + popover.connect_closed(move |p| { + p.unparent(); + if let Some(label) = label_for_close.upgrade() { + POPOVER_SLOT.take(&label); + } + }); +} + +#[cfg(test)] +mod tests { + use gtk4::prelude::*; + use gtk4::{self as gtk, glib}; + + use super::{ + CellEditor, GridMsg, POPOVER_SLOT, show_calendar_popover, show_json_popover, show_spin_button_popover, + }; + + #[gtk4::test] + fn calendar_selects_cell_date() { + let calendar = gtk::Calendar::new(); + let date = glib::DateTime::from_local(2026, 9, 15, 0, 0, 0.0).unwrap(); + + calendar.set_date(&date); + + assert_eq!(calendar.date().year(), 2026); + assert_eq!(calendar.date().month(), 9); + assert_eq!(calendar.date().day_of_month(), 15); + } + + #[derive(Clone, Copy, Debug)] + enum PopoverEditor { + Date, + Int, + Json, + } + + /// Commit through the editor's own control, which also closes it. + fn commit_popover(editor: PopoverEditor, popover: >k::Popover) { + use crate::test_support::first_descendant_of_type; + match editor { + PopoverEditor::Date => first_descendant_of_type::(popover) + .expect("the calendar") + .emit_by_name::<()>("day-selected", &[]), + PopoverEditor::Int => { + let spin = first_descendant_of_type::(popover).expect("the spin button"); + spin.set_value(42.0); + spin.emit_by_name::<()>("activate", &[]); + } + PopoverEditor::Json => { + first_descendant_of_type::(popover) + .expect("the JSON view") + .buffer() + .set_text("[1]"); + crate::test_support::descendants(popover) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.has_css_class("suggested-action")) + .expect("the Save button") + .emit_clicked(); + } + } + } + + /// Another test's window can take the focus and close an autohide + /// popover before it opens, so a dismissed one is shown again. + fn open_popover(editor: PopoverEditor, cell: &CellEditor, sender: &relm4::Sender) -> Option { + for _ in 0..5 { + match editor { + PopoverEditor::Date => show_calendar_popover(cell, 0, sender), + PopoverEditor::Int => show_spin_button_popover(cell, 0, sender, false), + PopoverEditor::Json => show_json_popover(cell, 0, sender), + } + let popover = crate::test_support::first_descendant_of_type::(cell)?; + let settled = crate::test_support::wait_until(std::time::Duration::from_secs(5), || { + popover.is_mapped() || !popover.is_visible() + }); + if settled.is_ok() && popover.is_mapped() { + return Some(popover); + } + if popover.parent().is_some() { + popover.unparent(); + } + POPOVER_SLOT.take(cell); + } + None + } + + #[gtk4::test] + fn a_closed_popover_editor_frees_its_cell() { + use crate::test_support::wait_until; + let timeout = std::time::Duration::from_secs(5); + let window = gtk::Window::new(); + window.present(); + for editor in [PopoverEditor::Date, PopoverEditor::Int, PopoverEditor::Json] { + let (sender, _receiver) = relm4::channel::(); + let cell = CellEditor::new(); + cell.set_text("x"); + window.set_child(Some(&cell)); + wait_until(timeout, || cell.is_mapped()).expect("the cell is drawn"); + let popover = open_popover(editor, &cell, &sender).expect("the editor popover opens"); + + commit_popover(editor, &popover); + wait_until(timeout, || popover.parent().is_none()).expect("the popover closes"); + assert_ne!(cell.text(), "x", "{editor:?} did not commit"); + assert!( + POPOVER_SLOT.take(&cell).is_none(), + "{editor:?} left its popover in the cell" + ); + // Only the cell is checked: GTK itself keeps a closed autohide + // popover alive once a child of it has taken focus. + let freed = cell.downgrade(); + window.set_child(None::<>k::Widget>); + drop((cell, popover)); + crate::test_support::drain_main_context(); + + assert!(freed.upgrade().is_none(), "{editor:?} kept its cell alive"); + } + window.destroy(); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_preset.rs b/linux/crates/app/src/ui/grid/cell_preset.rs new file mode 100644 index 0000000000..319403172f --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_preset.rs @@ -0,0 +1,5 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CellPreset { + Empty, + Null, +} diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs new file mode 100644 index 0000000000..3a940aa5fa --- /dev/null +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -0,0 +1,787 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; + +use tablepro_core::column::ResultColumn; +use tablepro_core::{ColumnInfo, QueryResult, Value}; + +use super::cell_editor::CellEditor; +use super::cell_editor_kind::{CellEditorKind, classify_editor_kind, is_bool_type}; +use super::context_menu::{CellMenuTarget, attach_cell_gesture}; +use super::display_text::{auto_filled_sentinel, editable_null_sentinel, value_to_display_text, value_to_edit_text}; +use super::grid_menus::{GridMenuInit, GridMenus, install_grid_context_menus}; +use super::inline_edit::{install_edit_commit_handler, install_edit_triggers}; +use super::widget_slot::{COLUMN_SLOT, POPOVER_SLOT, POSITION_SLOT, SNAPSHOT_SLOT, SUPPRESS_SLOT}; +use super::{GridMsg, RowObject, TabGridContext}; + +const PENDING_CSS_CLASSES: &[&str] = &[ + "tp-cell-modified", + "tp-row-pending-delete", + "tp-row-pending-insert", + "tp-row-leftmost-error-flash", +]; + +/// Shorter text fits a typical 200 px column, so its ellipsized label +/// needs no tooltip. +const TOOLTIP_MIN_CHARS: usize = 40; + +const WIDE_TABLE_THRESHOLD: usize = 8; +/// About 14 characters at the default font size. +const MIN_COLUMN_WIDTH_PX: i32 = 120; + +pub fn build_column_view( + result: &QueryResult, + schema_columns: &[ColumnInfo], + sender: relm4::Sender, + editable: bool, + sort: Option<(usize, bool)>, + sort_sender: Option>, + tab_ctx: TabGridContext, +) -> (gtk::ColumnView, gtk::MultiSelection) { + let store = gtk4::gio::ListStore::new::(); + for row in &result.rows { + store.append(&RowObject::new(row.clone())); + } + let selection = gtk::MultiSelection::new(Some(store)); + let column_view = gtk::ColumnView::builder() + .model(&selection) + .show_row_separators(true) + .show_column_separators(true) + .build(); + + let grid_menus = install_grid_context_menus(GridMenuInit { + column_view: &column_view, + sender: sender.clone(), + columns: result.columns.clone(), + truncated: result.truncated, + tab_ctx: tab_ctx.clone(), + editable, + }); + + // Sharing the viewport by `expand` alone leaves a wide table with 20 + // to 30 px columns that show nothing, so a column with no saved width + // starts at a readable minimum there. + let default_min_width = if result.columns.len() > WIDE_TABLE_THRESHOLD { + Some(MIN_COLUMN_WIDTH_PX) + } else { + None + }; + let mut columns: Vec = Vec::with_capacity(result.columns.len()); + for (i, column) in result.columns.iter().enumerate() { + // The catalogue is the authority on what can be edited: it + // knows the keys, the generated columns and the defaults. Until + // it has loaded, the type alone decides, which is what a + // result column can say. + let cell_editable = editable + && match schema_columns.get(i) { + Some(catalog) => is_cell_editable(catalog), + None => !is_bytes_kind(&column.column_type.kind), + }; + let col = build_column( + column, + i, + cell_editable, + schema_columns + .get(i) + .is_some_and(|catalog| catalog.is_auto_increment || catalog.is_generated), + sender.clone(), + sort_sender.clone(), + tab_ctx.clone(), + default_min_width, + column_view.downgrade(), + grid_menus.clone(), + ); + column_view.append_column(&col); + columns.push(col); + } + + // Applied before the notify handlers are wired, so it does not echo + // back as a SortChanged. + if let Some((col_idx, ascending)) = sort + && let Some(col) = columns.get(col_idx) + { + let direction = if ascending { + gtk::SortType::Ascending + } else { + gtk::SortType::Descending + }; + column_view.sort_by_column(Some(col), direction); + } + + if let Some(app_sender) = sort_sender + && let Some(view_sorter) = column_view + .sorter() + .and_then(|s| s.downcast::().ok()) + { + // A second click on the same column flips only + // `primary-sort-order`, so both notifies are wired. Each sends the + // sorter's post-click pair, which the receiver drops when it + // already matches. + let dispatch = { + let app_sender = app_sender.clone(); + let columns = columns.clone(); + move |sorter: >k::ColumnViewSorter| { + let Some(active) = sorter.primary_sort_column() else { + return; + }; + let ascending = matches!(sorter.primary_sort_order(), gtk::SortType::Ascending); + for (idx, col) in columns.iter().enumerate() { + if col == &active { + app_sender.send(GridMsg::SortChanged(idx, ascending)).ok(); + break; + } + } + } + }; + view_sorter.connect_primary_sort_column_notify({ + let dispatch = dispatch.clone(); + move |sorter| dispatch(sorter) + }); + view_sorter.connect_primary_sort_order_notify(move |sorter| dispatch(sorter)); + } + + (column_view, selection) +} + +#[expect( + clippy::too_many_arguments, + reason = "each argument is captured by a different column factory closure" +)] +fn build_column( + info: &ResultColumn, + idx: usize, + editable: bool, + // Whether the server fills this column in, so a draft row shows a + // placeholder rather than an empty cell the user should type in. + auto_filled: bool, + sender: relm4::Sender, + sort_sender: Option>, + tab_ctx: TabGridContext, + default_min_width: Option, + column_view: glib::WeakRef, + grid_menus: GridMenus, +) -> gtk::ColumnViewColumn { + let factory = gtk::SignalListItemFactory::new(); + + let column_data_type = info.column_type.name.clone(); + let column_name = info.name.clone(); + let accepts_empty = info.column_type.kind.accepts_empty_string(); + let column_view_for_setup = column_view.clone(); + factory.connect_setup(move |_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + if !editable { + setup_readonly_cell(item, idx, column_name.clone(), &column_view_for_setup, &grid_menus); + return; + } + if is_bool_type(&column_data_type) { + setup_bool_cell( + item, + idx, + column_name.clone(), + sender.clone(), + &column_view_for_setup, + &grid_menus, + ); + } else { + let editor_kind = classify_editor_kind(&column_data_type); + setup_editable_cell( + item, + idx, + column_name.clone(), + sender.clone(), + editor_kind, + accepts_empty, + &column_view_for_setup, + &grid_menus, + ); + } + }); + + let editable_for_bind = editable; + let column_auto_filled = auto_filled; + let tab_ctx_for_bind = tab_ctx.clone(); + factory.connect_bind(move |_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + let Some(row) = item.item().and_downcast::() else { + return; + }; + let (value, pending_classes) = tab_ctx_for_bind.bind_state(&row, idx); + let is_null = matches!(value, Value::Null); + // An editable cell shows NULL as ``, so it reads differently + // from an empty string; a read-only one dims a plain NULL. + let text = if is_null && column_auto_filled { + auto_filled_sentinel() + } else if editable_for_bind { + if is_null { + editable_null_sentinel() + } else { + value_to_edit_text(&value) + } + } else { + value_to_display_text(&value) + }; + + let is_pending_delete = pending_classes.contains(&"tp-row-pending-delete"); + let Some(child) = item.child() else { return }; + if let Ok(label) = child.clone().downcast::() { + label.set_text(&text); + apply_cell_tooltip(label.upcast_ref(), &text, is_null); + if is_null && !editable_for_bind { + label.add_css_class("dim-label"); + } else { + label.remove_css_class("dim-label"); + } + if is_null && (editable_for_bind || column_auto_filled) { + label.add_css_class("tp-null-sentinel"); + } else { + label.remove_css_class("tp-null-sentinel"); + } + clear_pending_classes(label.upcast_ref()); + for cls in &pending_classes { + label.add_css_class(cls); + } + label.set_strikethrough(is_pending_delete); + POSITION_SLOT.set(&label, item.position()); + } else if let Ok(checkbox) = child.clone().downcast::() { + // Suppressed, so drawing the value does not echo back as an edit. + SUPPRESS_SLOT.set(&checkbox, true); + match value { + Value::Bool(true) => { + checkbox.set_inconsistent(false); + checkbox.set_active(true); + } + Value::Bool(false) => { + checkbox.set_inconsistent(false); + checkbox.set_active(false); + } + Value::Null => { + checkbox.set_inconsistent(true); + checkbox.set_active(false); + } + _ => { + // A value that is not a bool shows as inconsistent + // rather than as a confidently wrong check box. + checkbox.set_inconsistent(true); + checkbox.set_active(false); + } + } + SUPPRESS_SLOT.set(&checkbox, false); + clear_pending_classes(checkbox.upcast_ref()); + for cls in &pending_classes { + checkbox.add_css_class(cls); + } + // A check box has no strikethrough, so a pending delete dims it. + checkbox.set_opacity(if is_pending_delete { 0.5 } else { 1.0 }); + POSITION_SLOT.set(&checkbox, item.position()); + } else if let Ok(label) = child.downcast::() { + label.set_text(&text); + apply_cell_tooltip(label.upcast_ref(), &text, is_null); + if is_null { + label.add_css_class("dim-label"); + } else { + label.remove_css_class("dim-label"); + } + clear_pending_classes(label.upcast_ref()); + for cls in &pending_classes { + label.add_css_class(cls); + } + set_label_strikethrough(&label, is_pending_delete); + POSITION_SLOT.set(&label, item.position()); + } + }); + + factory.connect_unbind(|_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + let Some(child) = item.child() else { return }; + if let Ok(label) = child.clone().downcast::() { + if label.is_editing() { + label.stop_editing(false); + } + // An open popover unparents before the cell is recycled, or + // GTK finalizes the cell with a GtkPopover child left in it. + if let Some(popover) = POPOVER_SLOT.take(&label) { + popover.popdown(); + } + POSITION_SLOT.take(&label); + SNAPSHOT_SLOT.take(&label); + } else if let Ok(checkbox) = child.clone().downcast::() { + POSITION_SLOT.take(&checkbox); + } else if let Ok(label) = child.downcast::() { + POSITION_SLOT.take(&label); + } + }); + + let column = gtk::ColumnViewColumn::builder() + .title(&info.name) + .factory(&factory) + .resizable(true) + .expand(true) + .build(); + if sort_sender.is_some() { + let dummy = gtk::CustomSorter::new(|_, _| gtk::Ordering::Equal); + column.set_sorter(Some(&dummy)); + } + if let Some(widths) = tab_ctx.column_widths.clone() { + if let Some(saved) = widths.width(&info.name) { + column.set_fixed_width(saved); + } else if let Some(min) = default_min_width { + column.set_fixed_width(min); + } + let column_name = info.name.clone(); + column.connect_fixed_width_notify(move |column| { + widths.record(&column_name, column.fixed_width()); + }); + } else if let Some(min) = default_min_width { + // Editor results remember no widths, but can be just as wide. + column.set_fixed_width(min); + } + column +} + +#[expect( + clippy::too_many_arguments, + reason = "each argument is captured by a different cell signal handler" +)] +fn setup_editable_cell( + item: >k::ListItem, + idx: usize, + column_name: String, + sender: relm4::Sender, + editor_kind: CellEditorKind, + accepts_empty: bool, + column_view: &glib::WeakRef, + menus: &GridMenus, +) { + let label = CellEditor::new(); + label.set_hexpand(true); + label.set_margin_start(8); + label.set_margin_end(8); + // Ctrl+Shift+N reads the focused cell's column from here. Its row + // changes as rows are recycled, so the bind writes POSITION_SLOT. + COLUMN_SLOT.set(&label, idx); + item.set_child(Some(&label)); + + attach_cell_gesture( + label.upcast_ref(), + column_view, + CellMenuTarget { + col_index: idx, + column_name, + editable: true, + text_editable: true, + accepts_empty, + }, + menus, + ); + install_edit_commit_handler(&label, idx, sender.clone()); + install_edit_triggers(&label, idx, sender, editor_kind); +} + +fn setup_bool_cell( + item: >k::ListItem, + idx: usize, + column_name: String, + sender: relm4::Sender, + column_view: &glib::WeakRef, + menus: &GridMenus, +) { + let checkbox = gtk::CheckButton::builder() + .halign(gtk::Align::Start) + .valign(gtk::Align::Center) + .margin_start(8) + .margin_end(8) + .build(); + COLUMN_SLOT.set(&checkbox, idx); + item.set_child(Some(&checkbox)); + + attach_cell_gesture( + checkbox.upcast_ref(), + column_view, + CellMenuTarget { + col_index: idx, + column_name, + editable: true, + text_editable: false, + accepts_empty: false, + }, + menus, + ); + checkbox.connect_toggled(move |cb| { + if SUPPRESS_SLOT.get(cb).unwrap_or(false) { + return; + } + let position = POSITION_SLOT.get(cb).unwrap_or(0); + let new_value = if cb.is_active() { "true" } else { "false" }; + sender + .send(GridMsg::CellEdited { + row_position: position, + col_index: idx, + new_value: new_value.to_string(), + }) + .ok(); + }); +} + +fn setup_readonly_cell( + item: >k::ListItem, + idx: usize, + column_name: String, + column_view: &glib::WeakRef, + menus: &GridMenus, +) { + let label = gtk::Label::builder() + .xalign(0.0) + .hexpand(true) + .selectable(true) + .ellipsize(gtk::pango::EllipsizeMode::End) + .margin_start(8) + .margin_end(8) + .build(); + item.set_child(Some(&label)); + attach_cell_gesture( + label.upcast_ref(), + column_view, + CellMenuTarget { + col_index: idx, + column_name, + editable: false, + text_editable: false, + accepts_empty: false, + }, + menus, + ); +} + +fn clear_pending_classes(widget: >k::Widget) { + for cls in PENDING_CSS_CLASSES { + widget.remove_css_class(cls); + } +} + +fn apply_cell_tooltip(widget: >k::Widget, text: &str, is_null: bool) { + if is_null || text.chars().take(TOOLTIP_MIN_CHARS + 1).count() <= TOOLTIP_MIN_CHARS { + widget.set_tooltip_text(None); + } else { + widget.set_tooltip_text(Some(text)); + } +} + +/// A Pango attribute, because CSS `text-decoration` does not cascade +/// reliably into a cell's label. +fn set_label_strikethrough(label: >k::Label, on: bool) { + if on { + let attrs = gtk::pango::AttrList::new(); + attrs.insert(gtk::pango::AttrInt::new_strikethrough(true)); + label.set_attributes(Some(&attrs)); + } else { + label.set_attributes(None); + } +} + +fn is_cell_editable(col: &ColumnInfo) -> bool { + // A primary key is the row's identity in the tracker, and the server + // rejects a value for a generated or auto-increment column. + !col.primary_key && !col.is_generated && !col.is_auto_increment && !is_bytes_kind(&col.column_type.kind) +} + +/// A blob has no text form to type into, so the grid never offers one. +fn is_bytes_kind(kind: &tablepro_core::column::ColumnKind) -> bool { + matches!(kind, tablepro_core::column::ColumnKind::Bytes { .. }) +} + +#[cfg(test)] +mod tests { + use gtk4::prelude::*; + use gtk4::{self as gtk, gio, glib}; + + use tablepro_core::{ColumnInfo, QueryResult, Value}; + + use super::{build_column_view, is_cell_editable}; + use crate::services::change_tracker::RowKey; + use crate::services::tab_tracker_handle::TabTrackerHandle; + use crate::ui::grid::{CellEditor, ColumnWidthBinding, GridMsg, RowObject, TabGridContext}; + + #[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" + )] + fn col(data_type: &str, primary_key: bool) -> ColumnInfo { + let kind = tablepro_core::column::classify_type_name(data_type); + ColumnInfo { + name: "x".into(), + column_type: tablepro_core::column::ColumnType { + name: data_type.to_owned(), + dynamic_storage: tablepro_core::column::has_dynamic_storage(&kind), + kind, + catalog: Some(tablepro_core::column::CatalogType { + declaration: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), + cast_target: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), + }), + read_form: tablepro_core::column::ReadForm::Native, + }, + nullable: true, + primary_key, + is_auto_increment: false, + is_generated: false, + default: tablepro_core::column::ColumnDefault::None, + comment: None, + } + } + + fn cell_texts(root: &impl IsA) -> Vec { + crate::test_support::descendants(root) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .map(|cell| cell.text().to_string()) + .collect() + } + + #[gtk4::test] + fn edit_during_rebind_shows_pending_value() { + use crate::services::tracker_edit::TrackerEdit; + + let mut id = col("integer", true); + id.name = "id".into(); + let mut name = col("text", false); + name.name = "name".into(); + let columns = vec![id, name]; + let result = QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(1), Value::Text("old".into())]], + ); + let tracker = TabTrackerHandle::default(); + let (sender, _receiver) = relm4::channel::(); + let context = TabGridContext { + tracker: Some(tracker.clone()), + pk_col_indices: vec![0], + column_widths: None, + }; + let (view, selection) = build_column_view(&result, &columns, sender, true, None, None, context); + let window = gtk::Window::new(); + window.set_child(Some(&view)); + window.present(); + crate::test_support::wait_until(std::time::Duration::from_secs(5), || { + cell_texts(&view).iter().any(|text| text == "old") + }) + .expect("the fetched value is drawn"); + + let delta = tracker.apply(TrackerEdit::Cell { + row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + col: 1, + original: Value::Text("old".into()), + value: Value::Text("new".into()), + }); + // A fresh row object, because the list item manager skips the + // rebind for an items-changed that keeps the same item. + let store = selection + .model() + .and_downcast::() + .expect("a list store"); + let row = store.item(0).and_downcast::().expect("a row"); + store.splice(0, 1, &[RowObject::new(row.cells_clone())]); + + crate::test_support::wait_until(std::time::Duration::from_secs(5), || { + cell_texts(&view).iter().any(|text| text == "new") + }) + .expect("the rebind draws the pending value"); + assert_eq!(delta.pending_count, 1); + window.close(); + crate::test_support::drain_main_context(); + } + + fn width_binding(tasks: &tablepro_session::runtime::Tasks, root: &tempfile::TempDir) -> ColumnWidthBinding { + ColumnWidthBinding { + store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), tasks), + connection_id: uuid::Uuid::new_v4(), + schema: None, + table: "t".to_owned(), + } + } + + #[gtk4::test] + fn column_freed_with_column_view() { + let runtime = crate::test_support::test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let mut name = col("text", false); + name.name = "name".into(); + let columns = vec![name]; + let result = QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Text("a".into())]], + ); + let (sender, _receiver) = relm4::channel::(); + let context = TabGridContext { + tracker: None, + pk_col_indices: Vec::new(), + column_widths: Some(width_binding(&runtime.tasks(), &root)), + }; + let (view, selection) = build_column_view( + &result, + &columns, + sender.clone(), + true, + Some((0, true)), + Some(sender), + context, + ); + let column = view + .columns() + .item(0) + .and_downcast::() + .expect("a column") + .downgrade(); + let view_ref = view.downgrade(); + + drop((view, selection)); + crate::test_support::drain_main_context(); + + assert!(column.upgrade().is_none(), "the column outlived its column view"); + assert!(view_ref.upgrade().is_none(), "the column view outlived its owner"); + } + + fn menu_gesture(cell: >k::Widget) -> Option { + let controllers = cell.observe_controllers(); + (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .find(|gesture| gesture.button() == 3) + } + + #[gtk4::test] + fn cells_freed_with_column_view() { + let mut id = col("integer", true); + id.name = "id".into(); + let mut name = col("text", false); + name.name = "name".into(); + let mut flag = col("boolean", false); + flag.name = "flag".into(); + let columns = vec![id, name, flag]; + let result = QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(7), Value::Text("a".into()), Value::Bool(true)]], + ); + let (sender, _receiver) = relm4::channel::(); + let context = TabGridContext { + tracker: Some(TabTrackerHandle::default()), + pk_col_indices: vec![0], + column_widths: None, + }; + let (view, selection) = build_column_view(&result, &columns, sender, true, None, None, context); + let window = gtk::Window::new(); + window.set_child(Some(&view)); + window.present(); + let cells = || -> Vec { + crate::test_support::descendants(&view) + .into_iter() + .filter(|widget| { + widget.is::() + || widget.is::() + || widget + .downcast_ref::() + .is_some_and(|label| label.text() == "7") + }) + .collect() + }; + crate::test_support::wait_until(std::time::Duration::from_secs(5), || cells().len() == 3) + .expect("the read-only, text and bool cells are drawn"); + // A right-click is what fills the menu's target, so it has to be + // in the picture too. + for cell in cells() { + let gesture = menu_gesture(&cell).expect("a cell answers a right-click"); + gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + } + let freed: Vec> = cells().iter().map(ObjectExt::downgrade).collect(); + + window.destroy(); + drop(window); + drop((view, selection)); + crate::test_support::drain_main_context(); + + let alive = freed.iter().filter(|cell| cell.upgrade().is_some()).count(); + assert_eq!(alive, 0, "cells outlived their column view"); + } + + #[test] + fn editable_for_normal_column() { + assert!(is_cell_editable(&col("text", false))); + assert!(is_cell_editable(&col("integer", false))); + } + + #[test] + fn not_editable_for_primary_key() { + assert!(!is_cell_editable(&col("integer", true))); + } + + #[test] + fn not_editable_for_generated_column() { + let mut c = col("integer", false); + c.is_generated = true; + assert!(!is_cell_editable(&c)); + } + + #[test] + fn not_editable_for_auto_increment_non_pk() { + let mut c = col("integer", false); + c.is_auto_increment = true; + assert!(!is_cell_editable(&c)); + } + + #[test] + fn not_editable_for_bytes() { + assert!(!is_cell_editable(&col("bytea", false))); + assert!(!is_cell_editable(&col("blob", false))); + assert!(!is_cell_editable(&col("longblob", false))); + assert!(!is_cell_editable(&col("BINARY", false))); + assert!(!is_cell_editable(&col("varbinary", false))); + } + + #[test] + fn only_columns_with_an_empty_value_accept_an_empty_string() { + // Clearing a text cell stores the empty string; clearing a + // number or a date can only mean NULL. + for text in ["text", "VARCHAR(255)", "char(3)", "longtext"] { + assert!( + col(text, false).column_type.kind.accepts_empty_string(), + "{text} should accept an empty string" + ); + } + for other in [ + "integer", + "bigint", + "numeric(10,2)", + "date", + "timestamp", + "uuid", + "boolean", + ] { + assert!( + !col(other, false).column_type.kind.accepts_empty_string(), + "{other} should not accept an empty string" + ); + } + } + + #[test] + fn a_blob_is_never_editable_whatever_the_engine_calls_it() { + for name in [ + "BYTEA", + "blob", + "LONGBLOB", + "mediumblob", + "tinyblob", + "VARBINARY", + "binary", + ] { + assert!( + !is_cell_editable(&col(name, false)), + "{name} was offered as an editable cell" + ); + } + assert!(is_cell_editable(&col("text", false))); + assert!(is_cell_editable(&col("integer", false))); + } +} diff --git a/linux/crates/app/src/ui/grid/column_width_binding.rs b/linux/crates/app/src/ui/grid/column_width_binding.rs new file mode 100644 index 0000000000..0d1acbbb3d --- /dev/null +++ b/linux/crates/app/src/ui/grid/column_width_binding.rs @@ -0,0 +1,31 @@ +/// The schema is part of it, so `public.users` and `audit.users` keep +/// their own widths. +#[derive(Clone)] +pub struct ColumnWidthBinding { + pub store: crate::persistence::ColumnWidthStore, + pub connection_id: uuid::Uuid, + pub schema: Option, + pub table: String, +} + +impl std::fmt::Debug for ColumnWidthBinding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ColumnWidthBinding") + .field("connection_id", &self.connection_id) + .field("schema", &self.schema) + .field("table", &self.table) + .finish() + } +} + +impl ColumnWidthBinding { + pub(super) fn width(&self, column: &str) -> Option { + self.store + .width(self.connection_id, self.schema.as_deref(), &self.table, column) + } + + pub(super) fn record(&self, column: &str, width: i32) { + self.store + .record(self.connection_id, self.schema.as_deref(), &self.table, column, width); + } +} diff --git a/linux/crates/app/src/ui/grid/context_menu.rs b/linux/crates/app/src/ui/grid/context_menu.rs new file mode 100644 index 0000000000..043ac0aecc --- /dev/null +++ b/linux/crates/app/src/ui/grid/context_menu.rs @@ -0,0 +1,128 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; + +use super::grid_menus::GridMenus; +use super::selection::select_row_for_menu; +use super::widget_slot::POSITION_SLOT; + +#[derive(Clone)] +pub(super) struct CellContext { + pub(super) widget: gtk::Widget, + pub(super) col_index: usize, + pub(super) column_name: String, +} + +/// The cell holds the menu through its gesture, so the menu holds the +/// cell weakly. +pub(super) struct ClickedCell { + widget: glib::WeakRef, + col_index: usize, + column_name: String, +} + +impl ClickedCell { + pub(super) fn get(&self) -> Option { + Some(CellContext { + widget: self.widget.upgrade()?, + col_index: self.col_index, + column_name: self.column_name.clone(), + }) + } +} + +#[derive(Clone)] +pub(super) struct CellMenuTarget { + pub(super) col_index: usize, + pub(super) column_name: String, + pub(super) editable: bool, + pub(super) text_editable: bool, + pub(super) accepts_empty: bool, +} + +pub(super) fn attach_cell_gesture( + widget: >k::Widget, + column_view: &glib::WeakRef, + target: CellMenuTarget, + menus: &GridMenus, +) { + let popover = if target.editable { + menus.editable_popover.clone() + } else { + menus.readonly_popover.clone() + }; + + let prepare = { + let context = menus.context.clone(); + let actions = menus.actions.clone(); + let target = target.clone(); + move |widget: >k::Widget, column_view: >k::ColumnView| { + *context.borrow_mut() = Some(ClickedCell { + widget: widget.downgrade(), + col_index: target.col_index, + column_name: target.column_name.clone(), + }); + actions.arm(&target); + if let Some(position) = POSITION_SLOT.get(widget) { + select_row_for_menu(column_view, position); + } + } + }; + + // The handlers below read the cell from their own arguments: the + // cell owns its controllers, so capturing it would be a cycle. + let view_for_gesture = column_view.clone(); + let popover_for_gesture = popover.clone(); + let prepare_for_gesture = prepare.clone(); + let gesture = gtk::GestureClick::new(); + gesture.set_button(3); + gesture.connect_pressed(move |g, _, x, y| { + let (Some(cv), Some(cell)) = (view_for_gesture.upgrade(), g.widget()) else { + return; + }; + g.set_state(gtk::EventSequenceState::Claimed); + prepare_for_gesture(&cell, &cv); + // The popover is parented to the column view, so it points in + // the view's coordinates, not the cell's. + let local = gtk::graphene::Point::new(x as f32, y as f32); + let (cv_x, cv_y) = cell + .compute_point(&cv, &local) + .map(|p| (p.x() as i32, p.y() as i32)) + .unwrap_or((x as i32, y as i32)); + popover_for_gesture.set_pointing_to(Some(>k::gdk::Rectangle::new(cv_x, cv_y, 1, 1))); + popover_for_gesture.popup(); + }); + widget.add_controller(gesture); + + let view_for_key = column_view.clone(); + let popover_for_key = popover; + let menu_shortcut = gtk::Shortcut::builder() + .trigger(>k::KeyvalTrigger::new( + gtk::gdk::Key::Menu, + gtk::gdk::ModifierType::empty(), + )) + .action(>k::CallbackAction::new(move |cell, _| { + let Some(cv) = view_for_key.upgrade() else { + return glib::Propagation::Proceed; + }; + prepare(cell, &cv); + // The Menu key has no pointer position, so the popover points + // at the whole cell. + if let Some(bounds) = cell.compute_bounds(&cv) { + let rect = gtk::gdk::Rectangle::new( + bounds.x() as i32, + bounds.y() as i32, + bounds.width() as i32, + bounds.height() as i32, + ); + popover_for_key.set_pointing_to(Some(&rect)); + } else { + popover_for_key.set_pointing_to(None); + } + popover_for_key.popup(); + glib::Propagation::Stop + })) + .build(); + let shortcut_controller = gtk::ShortcutController::new(); + shortcut_controller.add_shortcut(menu_shortcut); + widget.add_controller(shortcut_controller); +} diff --git a/linux/crates/app/src/ui/grid/display_text.rs b/linux/crates/app/src/ui/grid/display_text.rs new file mode 100644 index 0000000000..a8351fd9a3 --- /dev/null +++ b/linux/crates/app/src/ui/grid/display_text.rs @@ -0,0 +1,201 @@ +use tablepro_core::Value; + +/// A million-character value would hang the main thread in Pango +/// layout. The model keeps the whole value; only the cell is cut. +const DISPLAY_TEXT_MAX_CHARS: usize = 10_000; +/// UTF-8 is at most 4 bytes a char, so a shorter string skips the +/// O(n) char count. +const DISPLAY_TEXT_BYTES_THRESHOLD: usize = DISPLAY_TEXT_MAX_CHARS * 4; + +/// The brackets keep it apart from a literal `NULL` text value. +pub(crate) fn editable_null_sentinel() -> String { + crate::i18n::gettext("") +} + +/// A read-only cell dims its NULL instead, so it needs no brackets. +fn readonly_null_sentinel() -> String { + crate::i18n::gettext("NULL") +} + +/// For a column the server fills in on INSERT, so a new draft row does +/// not read as if its id were stored as NULL. +pub(super) fn auto_filled_sentinel() -> String { + crate::i18n::gettext("(auto)") +} + +pub(super) fn value_to_display_text(value: &Value) -> String { + match value { + Value::Null => readonly_null_sentinel(), + // A blob has no readable text form, so the grid says how big + // it is rather than showing bytes. + Value::Bytes(b) => format!("<{} bytes>", b.len()), + // The driver could not read this, and saying so is better than + // an empty cell that looks like a NULL. + Value::Undecodable(undecodable) => format!("", undecodable.type_name), + // Everything else renders the way it exports, so what the grid + // shows and what a copy produces are the same text. + other => match tablepro_core::export::value_to_text(other) { + Some(text) => truncate_for_display(&text), + None => readonly_null_sentinel(), + }, + } +} + +pub(super) fn value_to_edit_text(value: &Value) -> String { + match value { + Value::Null => String::new(), + other => value_to_display_text(other), + } +} + +fn truncate_for_display(s: &str) -> String { + if s.len() < DISPLAY_TEXT_BYTES_THRESHOLD { + return s.to_string(); + } + // Cut on a char boundary. + let mut cut = s.len(); + for (i, (byte_idx, _)) in s.char_indices().enumerate() { + if i >= DISPLAY_TEXT_MAX_CHARS { + cut = byte_idx; + break; + } + } + if cut >= s.len() { + return s.to_string(); + } + let head = &s[..cut]; + let remaining = s[cut..].chars().count(); + format!("{head}… (+{remaining} more chars)") +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + + use super::{truncate_for_display, value_to_display_text, value_to_edit_text}; + + #[test] + fn display_text_primitive_variants() { + assert_eq!(value_to_display_text(&Value::Null), "NULL"); + assert_eq!(value_to_display_text(&Value::Bool(true)), "true"); + assert_eq!(value_to_display_text(&Value::Int(42)), "42"); + assert_eq!(value_to_display_text(&Value::Text("hello".into())), "hello"); + assert_eq!(value_to_display_text(&Value::Bytes(vec![0u8; 16])), "<16 bytes>"); + } + + fn date_value(year: i32, month: u32, day: u32) -> Value { + Value::Date(tablepro_core::value::Temporal::Finite( + chrono::NaiveDate::from_ymd_opt(year, month, day).expect("a date"), + )) + } + + #[test] + fn display_text_temporal_variants() { + let date = chrono::NaiveDate::from_ymd_opt(2026, 4, 26).expect("a date"); + assert_eq!(value_to_display_text(&date_value(2026, 4, 26)), "2026-04-26"); + + let time = chrono::NaiveTime::from_hms_opt(14, 30, 0).expect("a time"); + assert_eq!( + value_to_display_text(&Value::Time(tablepro_core::value::SqlTime::from_time_of_day(time))), + "14:30:00" + ); + + let datetime = chrono::NaiveDateTime::new(date, time); + assert_eq!( + value_to_display_text(&Value::Timestamp(tablepro_core::value::Temporal::Finite(datetime))), + "2026-04-26 14:30:00" + ); + + let tz = chrono::DateTime::::from_naive_utc_and_offset(datetime, chrono::Utc); + assert_eq!( + value_to_display_text(&Value::TimestampTz(tablepro_core::value::Temporal::Finite( + tablepro_core::value::OffsetTimestamp::from_datetime(tz.fixed_offset()) + ))), + "2026-04-26 14:30:00+00:00" + ); + } + + #[test] + fn display_text_extended_variants() { + let dec: tablepro_core::value::SqlDecimal = "1234.56789".parse().expect("a decimal"); + assert_eq!(value_to_display_text(&Value::Decimal(dec)), "1234.56789"); + + let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").expect("a uuid"); + assert_eq!( + value_to_display_text(&Value::Uuid(id)), + "550e8400-e29b-41d4-a716-446655440000" + ); + + let json = tablepro_core::value::JsonText::parse(r#"{"a":1,"b":[2,3]}"#.to_owned()).expect("valid json"); + let text = value_to_display_text(&Value::Json(json)); + assert!(text.contains("\"a\":1"), "{text}"); + } + + #[test] + fn a_value_the_driver_could_not_read_says_so() { + let undecodable = Value::Undecodable(Box::new(tablepro_core::value::UndecodedValue { + type_name: "geography".to_owned(), + reason: tablepro_core::value::UndecodableReason::UnsupportedType, + })); + + let text = value_to_display_text(&undecodable); + + assert!(text.contains("geography"), "{text}"); + assert_ne!(text, "NULL", "an unreadable value looked like a missing one"); + } + + #[test] + fn edit_text_distinguishes_null_from_text_null() { + assert_eq!(value_to_edit_text(&Value::Null), ""); + assert_eq!(value_to_edit_text(&Value::Text("NULL".into())), "NULL"); + assert_eq!(value_to_edit_text(&Value::Int(0)), "0"); + } + + #[test] + fn edit_text_keeps_extended_variants_visible() { + assert_eq!(value_to_edit_text(&date_value(2026, 4, 26)), "2026-04-26"); + + let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + assert_eq!( + value_to_edit_text(&Value::Uuid(id)), + "550e8400-e29b-41d4-a716-446655440000" + ); + } + + #[test] + fn truncate_short_text_passes_through() { + let s = "hello world"; + assert_eq!(truncate_for_display(s), "hello world"); + } + + #[test] + fn truncate_caps_long_text_at_char_boundary() { + // 100k ASCII chars: should truncate to ~10k + suffix. + let s = "a".repeat(100_000); + let out = truncate_for_display(&s); + assert!(out.starts_with(&"a".repeat(10_000))); + assert!(out.contains("more chars")); + assert!(out.len() < 10_500); // ~10k chars + suffix bytes + } + + #[test] + fn truncate_handles_multibyte_boundary() { + // 30k 4-byte emoji (120k bytes): truncation must land on a + // char boundary, not mid-codepoint. + let s = "🦀".repeat(30_000); + let out = truncate_for_display(&s); + // Must be valid UTF-8. + assert!(std::str::from_utf8(out.as_bytes()).is_ok()); + // First DISPLAY_TEXT_MAX_CHARS chars should be 🦀 plus the + // suffix appended after. + assert!(out.contains("more chars")); + } + + #[test] + fn display_text_truncates_huge_text_value() { + let huge = "x".repeat(1_000_000); + let display = value_to_display_text(&Value::Text(huge)); + assert!(display.len() < 100_000); // bounded + assert!(display.contains("more chars")); + } +} diff --git a/linux/crates/app/src/ui/grid/grid_menus.rs b/linux/crates/app/src/ui/grid/grid_menus.rs new file mode 100644 index 0000000000..0377d67d5f --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_menus.rs @@ -0,0 +1,469 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::{self as gtk, gio}; + +use tablepro_core::column::ResultColumn; + +use super::cell_editor::CellEditor; +use super::context_menu::{CellContext, CellMenuTarget, ClickedCell}; +use super::inline_edit::enter_edit_mode; +use super::row_json_dialog::show_row_json_dialog; +use super::selection::{export_snapshot, row_at, rows_for_menu}; +use super::widget_slot::POSITION_SLOT; +use super::{CellPreset, GridMsg, TabGridContext}; + +/// Enabled per right-clicked cell. Their items are hidden while +/// disabled, so a cell they do not apply to shows no dead entry. `None` +/// on a read-only grid, which registers neither. +#[derive(Clone, Default)] +pub(super) struct CellActions { + edit: Option, + set_empty: Option, +} + +impl CellActions { + pub(super) fn arm(&self, target: &CellMenuTarget) { + if let Some(edit) = &self.edit { + edit.set_enabled(target.text_editable); + } + if let Some(set_empty) = &self.set_empty { + set_empty.set_enabled(target.accepts_empty); + } + } +} + +#[derive(Clone)] +pub(super) struct GridMenus { + pub(super) context: Rc>>, + pub(super) editable_popover: gtk::PopoverMenu, + pub(super) readonly_popover: gtk::PopoverMenu, + pub(super) actions: CellActions, +} + +#[derive(Clone, Copy)] +struct MenuShape { + edit_cell: bool, + set_value: bool, + row_ops: bool, +} + +impl MenuShape { + const READ_ONLY: MenuShape = MenuShape { + edit_cell: false, + set_value: false, + row_ops: false, + }; + const ROW_OPS: MenuShape = MenuShape { + edit_cell: false, + set_value: false, + row_ops: true, + }; + const FULL: MenuShape = MenuShape { + edit_cell: true, + set_value: true, + row_ops: true, + }; +} + +fn build_cell_menu(shape: MenuShape) -> gio::Menu { + let menu = gio::Menu::new(); + if shape.edit_cell { + let edit_section = gio::Menu::new(); + let edit_item = gio::MenuItem::new(Some(&crate::i18n::gettext("Edit cell")), Some("cell.edit")); + edit_item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); + edit_section.append_item(&edit_item); + menu.append_section(None, &edit_section); + } + + let copy_as = gio::Menu::new(); + copy_as.append(Some(&crate::i18n::gettext("Rows")), Some("cell.copy-rows")); + copy_as.append( + Some(&crate::i18n::gettext("With Headers")), + Some("cell.copy-rows-headers"), + ); + copy_as.append(Some(&crate::i18n::gettext("JSON")), Some("cell.copy-json")); + copy_as.append(Some(&crate::i18n::gettext("CSV")), Some("cell.copy-csv")); + copy_as.append( + Some(&crate::i18n::gettext("CSV with Headers")), + Some("cell.copy-csv-headers"), + ); + copy_as.append(Some(&crate::i18n::gettext("Markdown")), Some("cell.copy-markdown")); + copy_as.append(Some(&crate::i18n::gettext("IN Clause")), Some("cell.copy-in-clause")); + if shape.row_ops { + let sql_section = gio::Menu::new(); + sql_section.append( + Some(&crate::i18n::gettext("INSERT Statement")), + Some("cell.copy-row-insert"), + ); + copy_as.append_section(None, &sql_section); + } + let copy_section = gio::Menu::new(); + copy_section.append(Some(&crate::i18n::gettext("Copy")), Some("cell.copy")); + copy_section.append_submenu(Some(&crate::i18n::gettext("Copy as")), ©_as); + copy_section.append( + Some(&crate::i18n::gettext("Copy column name")), + Some("cell.copy-column-name"), + ); + menu.append_section(None, ©_section); + + let json_section = gio::Menu::new(); + json_section.append( + Some(&crate::i18n::gettext("Show Row as JSON")), + Some("cell.show-row-json"), + ); + menu.append_section(None, &json_section); + + let action_section = gio::Menu::new(); + if shape.set_value { + let set_value = gio::Menu::new(); + // Only a free-text column can hold an empty string: elsewhere + // "Empty" would either be rejected by the server or mean NULL, + // which the item below already says plainly. + let empty_item = gio::MenuItem::new(Some(&crate::i18n::gettext("Empty")), Some("cell.set-empty")); + empty_item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); + set_value.append_item(&empty_item); + set_value.append(Some("NULL"), Some("cell.set-null")); + action_section.append_submenu(Some(&crate::i18n::gettext("Set Value")), &set_value); + } + action_section.append(Some(&crate::i18n::gettext("Export Results…")), Some("cell.export")); + if shape.row_ops { + action_section.append(Some(&crate::i18n::gettext("Insert row")), Some("cell.insert-row")); + action_section.append(Some(&crate::i18n::gettext("Duplicate")), Some("cell.duplicate-row")); + action_section.append(Some(&crate::i18n::gettext("Delete")), Some("cell.delete-row")); + } + menu.append_section(None, &action_section); + menu +} + +pub(super) struct GridMenuInit<'a> { + pub(super) column_view: &'a gtk::ColumnView, + pub(super) sender: relm4::Sender, + pub(super) columns: std::sync::Arc<[ResultColumn]>, + /// From the fetch that filled the grid, so Export Results reports the + /// same truncation the paginator does. + pub(super) truncated: bool, + pub(super) tab_ctx: TabGridContext, + pub(super) editable: bool, +} + +pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { + let GridMenuInit { + column_view, + sender, + columns, + truncated, + tab_ctx, + editable, + } = init; + let context: Rc>> = Rc::new(RefCell::new(None)); + // Every action closure holds the view weakly. The action group + // belongs to the ColumnView, so a strong clone in a closure is a + // cycle: the grid, its selection model, its store and every row it + // holds would outlive the page that built them. + let view = column_view.downgrade(); + let tab = Rc::new(tab_ctx); + + let group = gio::SimpleActionGroup::new(); + let slot_position = |slot: &CellContext| POSITION_SLOT.get(&slot.widget).unwrap_or(0); + + macro_rules! cell_action { + ($name:literal, |$slot:ident| $body:expr) => {{ + let ctx = context.clone(); + gio::ActionEntry::builder($name) + .activate(move |_, _, _| { + let clicked = ctx.borrow().as_ref().and_then(ClickedCell::get); + if let Some($slot) = clicked.as_ref() { + $body; + } + }) + .build() + }}; + } + fn clipboard_message(encoded: Result) -> GridMsg { + match encoded { + Ok(text) => GridMsg::CopyToClipboard(text), + Err(error) => GridMsg::ShowToast(crate::i18n::gettext_f( + "Couldn't copy the selection: {error}", + &[("error", &error.to_string())], + )), + } + } + macro_rules! copy_action { + ($name:literal, |$slot:ident, $rows:ident| $message:expr) => {{ + let s = sender.clone(); + let view = view.clone(); + let tab = tab.clone(); + cell_action!($name, |$slot| { + let Some(cv) = view.upgrade() else { return }; + let $rows = rows_for_menu(&cv, &tab, slot_position($slot)); + s.send($message).ok(); + }) + }}; + } + macro_rules! send_action { + ($name:literal, |$slot:ident| $msg:expr) => {{ + let s = sender.clone(); + cell_action!($name, |$slot| s.send($msg).ok()) + }}; + } + + let edit_action = cell_action!("edit", |slot| { + if let Ok(label) = slot.widget.clone().downcast::() { + enter_edit_mode(&label); + } + }); + // Plain Copy reads the cell's value, never the widget's text: the + // widget carries the display form, which is the `` sentinel, + // the `(auto)` placeholder, `` for a blob and a value cut + // at the 10k display cap. + let copy_action = { + let s = sender.clone(); + let view = view.clone(); + let tab = tab.clone(); + let cols = columns.clone(); + cell_action!("copy", |slot| { + let Some(cv) = view.upgrade() else { return }; + let rows = rows_for_menu(&cv, &tab, slot_position(slot)); + let message = match rows.as_slice() { + [row] => GridMsg::CopyToClipboard( + row.get(slot.col_index) + .and_then(|value| tablepro_core::export::cell_text(&cols, slot.col_index, value)) + .unwrap_or_default(), + ), + many => clipboard_message(tablepro_core::export::render_tsv(&cols, many, false)), + }; + s.send(message).ok(); + }) + }; + let copy_rows_action = { + let cols = columns.clone(); + copy_action!("copy-rows", |_slot, rows| clipboard_message( + tablepro_core::export::render_tsv(&cols, &rows, false) + )) + }; + let copy_rows_headers_action = { + let cols = columns.clone(); + copy_action!("copy-rows-headers", |_slot, rows| clipboard_message( + tablepro_core::export::render_tsv(&cols, &rows, true) + )) + }; + let copy_json_action = { + let cols = columns.clone(); + copy_action!("copy-json", |_slot, rows| GridMsg::CopyToClipboard( + tablepro_core::export::render_json(&cols, &rows) + )) + }; + let copy_csv_action = { + let cols = columns.clone(); + copy_action!("copy-csv", |_slot, rows| clipboard_message( + tablepro_core::export::render_csv( + &cols, + &rows, + &tablepro_core::export::CsvOptions { + header_row: false, + ..Default::default() + } + ) + )) + }; + let copy_csv_headers_action = { + let cols = columns.clone(); + copy_action!("copy-csv-headers", |_slot, rows| clipboard_message( + tablepro_core::export::render_csv(&cols, &rows, &tablepro_core::export::CsvOptions::default()) + )) + }; + let copy_markdown_action = { + let cols = columns.clone(); + copy_action!("copy-markdown", |_slot, rows| GridMsg::CopyToClipboard( + tablepro_core::export::render_markdown(&cols, &rows) + )) + }; + // NULL and binary values have no place in an IN list, so the + // clause says how many it left out instead of handing back a + // shorter list that quietly selects different rows. + let copy_in_clause_action = { + let s = sender.clone(); + let view = view.clone(); + let tab = tab.clone(); + cell_action!("copy-in-clause", |slot| { + let Some(cv) = view.upgrade() else { return }; + let rows = rows_for_menu(&cv, &tab, slot_position(slot)); + let clause = tablepro_core::export::render_in_clause(&rows, slot.col_index); + if clause.sql.is_empty() { + s.send(GridMsg::ShowToast(crate::i18n::gettext( + "Nothing to copy: an IN clause can't carry NULL or binary values", + ))) + .ok(); + return; + } + s.send(GridMsg::CopyToClipboard(clause.sql)).ok(); + if clause.skipped > 0 { + s.send(GridMsg::ShowToast(crate::i18n::gettext_f( + "{n} NULL or binary values left out of the IN clause", + &[("n", &clause.skipped.to_string())], + ))) + .ok(); + } + }) + }; + let copy_column_name_action = send_action!("copy-column-name", |slot| GridMsg::CopyToClipboard( + slot.column_name.clone() + )); + let copy_row_insert_action = send_action!("copy-row-insert", |slot| GridMsg::CopyRowAsInsert { + row_position: slot_position(slot), + }); + let show_row_json_action = { + let cols = columns.clone(); + let view = view.clone(); + let tab = tab.clone(); + cell_action!("show-row-json", |slot| { + let Some(cv) = view.upgrade() else { return }; + let Some(row) = row_at(&cv, slot_position(slot)) else { + return; + }; + let text = tablepro_core::export::row_to_json(&cols, &tab.effective_cells(&row)); + show_row_json_dialog(&cv, text); + }) + }; + let set_empty_action = send_action!("set-empty", |slot| GridMsg::SetCellValue { + row_position: slot_position(slot), + col_index: slot.col_index, + preset: CellPreset::Empty, + }); + let set_null_action = send_action!("set-null", |slot| GridMsg::SetCellValue { + row_position: slot_position(slot), + col_index: slot.col_index, + preset: CellPreset::Null, + }); + let export_action = { + let s = sender.clone(); + let view = view.clone(); + let cols = columns.clone(); + let tab = tab.clone(); + gio::ActionEntry::builder("export") + .activate(move |_, _, _| { + let Some(cv) = view.upgrade() else { return }; + s.send(GridMsg::ExportResults(export_snapshot(&cv, &cols, truncated, &tab))) + .ok(); + }) + .build() + }; + let insert_row_action = { + let s = sender.clone(); + gio::ActionEntry::builder("insert-row") + .activate(move |_, _, _| { + s.send(GridMsg::InsertRow).ok(); + }) + .build() + }; + let delete_row_action = send_action!("delete-row", |slot| GridMsg::DeleteRowAt { + row_position: slot_position(slot), + }); + let duplicate_row_action = send_action!("duplicate-row", |slot| GridMsg::DuplicateRow { + row_position: slot_position(slot), + }); + + let mut entries = vec![ + copy_action, + copy_rows_action, + copy_rows_headers_action, + copy_json_action, + copy_csv_action, + copy_csv_headers_action, + copy_markdown_action, + copy_in_clause_action, + copy_column_name_action, + show_row_json_action, + export_action, + ]; + // A read-only grid registers none of the mutating actions: it + // shows no menu item for them, and the editor throws their + // messages away at the far end. + if editable { + entries.extend([ + edit_action, + set_empty_action, + set_null_action, + copy_row_insert_action, + insert_row_action, + delete_row_action, + duplicate_row_action, + ]); + } + group.add_action_entries(entries); + column_view.insert_action_group("cell", Some(&group)); + + let actions = CellActions { + edit: editable.then(|| simple_action(&group, "edit")).flatten(), + set_empty: editable.then(|| simple_action(&group, "set-empty")).flatten(), + }; + + // Popovers are parented eagerly so each PopoverMenu's action muxer + // snapshots the ColumnView's `cell` group at set_parent() time. + // Lazy parenting in the gesture handler drops every activation + // silently. + let make_popover = |shape: MenuShape| { + let popover = gtk::PopoverMenu::from_model_full(&build_cell_menu(shape), gtk::PopoverMenuFlags::NESTED); + popover.set_has_arrow(true); + popover.set_parent(column_view); + popover + }; + // A read-only grid has a single shape and both fields name it. + let (editable_popover, readonly_popover) = if editable { + (make_popover(MenuShape::FULL), make_popover(MenuShape::ROW_OPS)) + } else { + let readonly = make_popover(MenuShape::READ_ONLY); + (readonly.clone(), readonly) + }; + + let mut popovers_for_destroy = vec![editable_popover.clone()]; + if readonly_popover != editable_popover { + popovers_for_destroy.push(readonly_popover.clone()); + } + + if editable { + let empty_menu = gio::Menu::new(); + empty_menu.append(Some(&crate::i18n::gettext("Insert row")), Some("cell.insert-row")); + let empty_popover = gtk::PopoverMenu::from_model_full(&empty_menu, gtk::PopoverMenuFlags::NESTED); + empty_popover.set_has_arrow(true); + empty_popover.set_parent(column_view); + popovers_for_destroy.push(empty_popover.clone()); + + let view_for_empty = view.clone(); + let empty_gesture = gtk::GestureClick::builder().button(3).build(); + empty_gesture.connect_pressed(move |g, _, x, y| { + let Some(cv) = view_for_empty.upgrade() else { return }; + let cv_widget: gtk::Widget = cv.clone().upcast(); + if let Some(picked) = cv.pick(x, y, gtk::PickFlags::DEFAULT) + && picked != cv_widget + { + return; + } + g.set_state(gtk::EventSequenceState::Claimed); + empty_popover.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); + empty_popover.popup(); + }); + column_view.add_controller(empty_gesture); + } + + column_view.connect_destroy(move |_| { + for popover in &popovers_for_destroy { + popover.unparent(); + } + }); + + GridMenus { + context, + editable_popover, + readonly_popover, + actions, + } +} + +fn simple_action(group: &gio::SimpleActionGroup, name: &str) -> Option { + group + .lookup_action(name) + .and_then(|a| a.downcast::().ok()) +} diff --git a/linux/crates/app/src/ui/grid/grid_msg.rs b/linux/crates/app/src/ui/grid/grid_msg.rs new file mode 100644 index 0000000000..0a4d1ba101 --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_msg.rs @@ -0,0 +1,36 @@ +use tablepro_core::QueryResult; + +use super::CellPreset; + +#[derive(Debug)] +pub enum GridMsg { + /// The sorter's state after the click, not a toggle hint: a click on + /// another column fires both sort notifies, and a hint would flip twice. + SortChanged(usize, bool), + CellEdited { + row_position: u32, + col_index: usize, + new_value: String, + }, + CopyToClipboard(String), + ShowToast(String), + CopyRowAsInsert { + row_position: u32, + }, + /// An intent, not a `Value`: the grid does not know the column's + /// declared type, and `Text("")` in an `int` or a `date` column is an + /// UPDATE the server rejects. + SetCellValue { + row_position: u32, + col_index: usize, + preset: CellPreset, + }, + ExportResults(QueryResult), + DeleteRowAt { + row_position: u32, + }, + InsertRow, + DuplicateRow { + row_position: u32, + }, +} diff --git a/linux/crates/app/src/ui/grid/inline_edit.rs b/linux/crates/app/src/ui/grid/inline_edit.rs new file mode 100644 index 0000000000..7feea78a8a --- /dev/null +++ b/linux/crates/app/src/ui/grid/inline_edit.rs @@ -0,0 +1,187 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; + +use super::GridMsg; +use super::cell_editor::CellEditor; +use super::cell_editor_kind::CellEditorKind; +use super::cell_popovers::{show_calendar_popover, show_json_popover, show_spin_button_popover}; +use super::display_text::editable_null_sentinel; +use super::widget_slot::{POSITION_SLOT, PREEDIT_SLOT, SNAPSHOT_SLOT}; + +#[derive(Debug)] +pub(super) struct EditSnapshot { + position: u32, + original: String, +} + +/// Clears the `` sentinel first, so the user does not type over it. +pub(super) fn enter_edit_mode(label: &CellEditor) { + if label.text().as_str() == editable_null_sentinel() { + label.set_text(""); + } + label.start_editing(); +} + +/// `child_focus` on the window is what a real Tab press does. +fn move_focus(widget: &impl IsA, direction: gtk::DirectionType) { + let Some(root) = widget.root() else { return }; + let Ok(window) = root.dynamic_cast::() else { + return; + }; + window.child_focus(direction); +} + +pub(super) fn install_edit_triggers( + label: &CellEditor, + col_index: usize, + sender: relm4::Sender, + editor_kind: CellEditorKind, +) { + let trigger: std::rc::Rc = match editor_kind { + CellEditorKind::Text => std::rc::Rc::new(|l: &CellEditor| enter_edit_mode(l)), + CellEditorKind::Date => { + let sender = sender.clone(); + std::rc::Rc::new(move |l| show_calendar_popover(l, col_index, &sender)) + } + CellEditorKind::Int => { + let sender = sender.clone(); + std::rc::Rc::new(move |l| show_spin_button_popover(l, col_index, &sender, false)) + } + CellEditorKind::Float => { + let sender = sender.clone(); + std::rc::Rc::new(move |l| show_spin_button_popover(l, col_index, &sender, true)) + } + CellEditorKind::Json => { + let sender = sender.clone(); + std::rc::Rc::new(move |l| show_json_popover(l, col_index, &sender)) + } + }; + + // Editing takes a double-click, since a single click in a grid + // selects the row. Capture phase, because the column view's row + // selection takes the press in the bubble phase. + // + // The handlers read the cell from their controller: the cell owns + // both controllers, so capturing it would be a cycle. + let gesture = gtk::GestureClick::builder().button(gtk::gdk::BUTTON_PRIMARY).build(); + gesture.set_propagation_phase(gtk::PropagationPhase::Capture); + let trigger_for_press = trigger.clone(); + gesture.connect_pressed(move |gesture, n_press, _, _| { + if n_press != 2 { + return; + } + let Some(label) = gesture.widget().and_downcast::() else { + return; + }; + gesture.set_state(gtk::EventSequenceState::Claimed); + trigger_for_press(&label); + }); + label.add_controller(gesture); + + // While editing, Return and Escape belong to the CellEditor's entry. + let controller = gtk::EventControllerKey::new(); + let trigger_for_key = trigger; + controller.connect_key_pressed(move |controller, keyval, _, modifiers| { + let Some(label) = controller.widget().and_downcast::() else { + return glib::Propagation::Proceed; + }; + let editing = label.is_editing(); + let shift = modifiers.contains(gtk::gdk::ModifierType::SHIFT_MASK); + + if !editing { + match keyval { + gtk::gdk::Key::F2 | gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter => { + trigger_for_key(&label); + return glib::Propagation::Stop; + } + // Left to GTK, Tab leaves the grid for the paginator. + gtk::gdk::Key::Tab if !shift => { + move_focus(&label, gtk::DirectionType::TabForward); + return glib::Propagation::Stop; + } + gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab if shift => { + move_focus(&label, gtk::DirectionType::TabBackward); + return glib::Propagation::Stop; + } + // The column view moves between rows only. + gtk::gdk::Key::Right => { + move_focus(&label, gtk::DirectionType::TabForward); + return glib::Propagation::Stop; + } + gtk::gdk::Key::Left => { + move_focus(&label, gtk::DirectionType::TabBackward); + return glib::Propagation::Stop; + } + _ => {} + } + return glib::Propagation::Proceed; + } + + match keyval { + gtk::gdk::Key::Tab if !shift => { + label.stop_editing(true); + move_focus(&label, gtk::DirectionType::TabForward); + glib::Propagation::Stop + } + gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab if shift => { + label.stop_editing(true); + move_focus(&label, gtk::DirectionType::TabBackward); + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, + } + }); + label.add_controller(controller); +} + +/// Focus moving to an IME popover ends the edit mid-composition, so the +/// commit waits until the preedit clears. +pub(super) fn install_edit_commit_handler(label: &CellEditor, col_index: usize, sender: relm4::Sender) { + { + let text = label.entry(); + // The entry is the cell's own child, so it holds the cell weakly. + let label_for_preedit = label.downgrade(); + let sender_for_preedit = sender.clone(); + text.connect_preedit_changed(move |_t, preedit| { + let Some(label_for_preedit) = label_for_preedit.upgrade() else { + return; + }; + let active = !preedit.is_empty(); + PREEDIT_SLOT.set(&label_for_preedit, active); + // The edit ended during the composition, so this is its commit. + if !active && !label_for_preedit.is_editing() { + commit_cell_edit(&label_for_preedit, col_index, &sender_for_preedit); + } + }); + } + + label.connect_editing_notify(move |label| { + if label.is_editing() { + let position = POSITION_SLOT.get(label).unwrap_or(0); + let original = label.text().to_string(); + SNAPSHOT_SLOT.set(label, EditSnapshot { position, original }); + return; + } + if PREEDIT_SLOT.get(label).unwrap_or(false) { + return; + } + commit_cell_edit(label, col_index, &sender); + }); +} + +fn commit_cell_edit(label: &CellEditor, col_index: usize, sender: &relm4::Sender) { + let Some(snap) = SNAPSHOT_SLOT.take(label) else { + return; + }; + let new_value = label.text().to_string(); + if new_value == snap.original { + return; + } + sender + .send(GridMsg::CellEdited { + row_position: snap.position, + col_index, + new_value, + }) + .ok(); +} diff --git a/linux/crates/app/src/ui/grid/mod.rs b/linux/crates/app/src/ui/grid/mod.rs new file mode 100644 index 0000000000..828058efac --- /dev/null +++ b/linux/crates/app/src/ui/grid/mod.rs @@ -0,0 +1,26 @@ +mod cell_editor; +mod cell_editor_kind; +mod cell_popovers; +mod cell_preset; +mod column_factory; +mod column_width_binding; +mod context_menu; +mod display_text; +mod grid_menus; +mod grid_msg; +mod inline_edit; +mod row_json_dialog; +mod row_object; +mod selection; +mod tab_grid_context; +mod widget_slot; + +pub(crate) use self::cell_editor::CellEditor; +pub(crate) use self::cell_preset::CellPreset; +pub(crate) use self::column_factory::build_column_view; +pub(crate) use self::column_width_binding::ColumnWidthBinding; +pub(crate) use self::display_text::editable_null_sentinel; +pub(crate) use self::grid_msg::GridMsg; +pub(crate) use self::row_object::RowObject; +pub(crate) use self::selection::{export_snapshot, focused_cell_coords, selected_positions}; +pub(crate) use self::tab_grid_context::TabGridContext; diff --git a/linux/crates/app/src/ui/grid/row_json_dialog.rs b/linux/crates/app/src/ui/grid/row_json_dialog.rs new file mode 100644 index 0000000000..2909d6ed7b --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_json_dialog.rs @@ -0,0 +1,43 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use relm4::adw; +use relm4::adw::prelude::*; +use sourceview5::prelude::*; + +pub(super) fn show_row_json_dialog(parent: &impl IsA, json: String) { + let buffer = sourceview5::Buffer::new(None); + if let Some(lang) = sourceview5::LanguageManager::default().language("json") { + buffer.set_language(Some(&lang)); + } + let scheme_name = if adw::StyleManager::default().is_dark() { + "Adwaita-dark" + } else { + "Adwaita" + }; + buffer.set_style_scheme(sourceview5::StyleSchemeManager::default().scheme(scheme_name).as_ref()); + buffer.set_text(&json); + let view = sourceview5::View::with_buffer(&buffer); + view.set_editable(false); + view.set_monospace(true); + view.set_show_line_numbers(true); + view.set_top_margin(8); + view.set_left_margin(8); + let scrolled = gtk::ScrolledWindow::builder().child(&view).vexpand(true).build(); + + let copy_button = gtk::Button::from_icon_name(crate::ui::icons::EDIT_COPY); + copy_button.set_tooltip_text(Some(&crate::i18n::gettext("Copy"))); + copy_button.connect_clicked(move |b| b.clipboard().set_text(&json)); + let header = adw::HeaderBar::new(); + header.pack_end(©_button); + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&scrolled)); + + adw::Dialog::builder() + .title(crate::i18n::gettext("Row as JSON")) + .content_width(560) + .content_height(480) + .child(&toolbar) + .build() + .present(Some(parent)); +} diff --git a/linux/crates/app/src/ui/row_object.rs b/linux/crates/app/src/ui/grid/row_object.rs similarity index 69% rename from linux/crates/app/src/ui/row_object.rs rename to linux/crates/app/src/ui/grid/row_object.rs index 2acfa1eab0..bb30abab48 100644 --- a/linux/crates/app/src/ui/row_object.rs +++ b/linux/crates/app/src/ui/grid/row_object.rs @@ -11,11 +11,7 @@ mod imp { #[derive(Default)] pub struct RowObject { pub cells: RefCell>, - /// Local id for draft (uninserted) rows added via the - /// inline-Insert flow. `None` for persisted rows fetched - /// from the database. Lets `connect_bind` distinguish a - /// draft (which needs the green-border CSS + RowKey::Draft - /// tracker lookup) from a persisted row whose PK columns + /// Tells a draft apart from a persisted row whose key columns /// happen to be NULL. pub draft_id: Cell>, } @@ -58,16 +54,11 @@ impl RowObject { self.imp().cells.borrow().clone() } - /// Borrow the cell slice for the duration of `f`. Use this from - /// hot paths (filter / sort comparators) to avoid the per-call - /// allocation that `cells_clone` incurs. + /// Spares a hot path the copy `cells_clone` makes. pub fn with_cells(&self, f: impl FnOnce(&[Value]) -> R) -> R { f(&self.imp().cells.borrow()) } - /// In-place cell mutation. Used by the inline-edit flow on draft - /// rows so the grid renders the user's typed value immediately - /// instead of waiting for a re-fetch. pub fn set_cell(&self, idx: usize, value: Value) { let mut cells = self.imp().cells.borrow_mut(); if idx < cells.len() { diff --git a/linux/crates/app/src/ui/grid/selection.rs b/linux/crates/app/src/ui/grid/selection.rs new file mode 100644 index 0000000000..1227844066 --- /dev/null +++ b/linux/crates/app/src/ui/grid/selection.rs @@ -0,0 +1,83 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +use tablepro_core::column::ResultColumn; +use tablepro_core::{QueryResult, Value}; + +use super::widget_slot::{COLUMN_SLOT, POSITION_SLOT}; +use super::{RowObject, TabGridContext}; + +pub(crate) fn selected_positions(selection: >k::MultiSelection) -> Vec { + let bitset = selection.selection(); + let mut out = Vec::with_capacity(bitset.size() as usize); + for i in 0..bitset.size() { + out.push(bitset.nth(i as u32)); + } + out.sort_unstable(); + out +} + +pub(super) fn row_at(column_view: >k::ColumnView, position: u32) -> Option { + column_view.model()?.item(position)?.downcast::().ok() +} + +fn selection_of(column_view: >k::ColumnView) -> Option { + column_view.model()?.downcast::().ok() +} + +/// A right-click on a selected row keeps the selection, so a block +/// still copies whole; any other row becomes the selection. That keeps +/// Copy and Delete in one menu pointing at the same rows. +pub(super) fn select_row_for_menu(column_view: >k::ColumnView, position: u32) { + let Some(selection) = selection_of(column_view) else { + return; + }; + if position >= selection.n_items() || selection.is_selected(position) { + return; + } + selection.select_item(position, true); +} + +/// `clicked` is the fallback for the keyboard path on a grid whose +/// selection is empty. +pub(super) fn rows_for_menu(column_view: >k::ColumnView, ctx: &TabGridContext, clicked: u32) -> Vec> { + let mut positions = selection_of(column_view) + .map(|s| selected_positions(&s)) + .unwrap_or_default(); + if positions.is_empty() { + positions.push(clicked); + } + positions + .iter() + .filter_map(|p| row_at(column_view, *p)) + .map(|r| ctx.effective_cells(&r)) + .collect() +} + +/// The paginator's Export button and the menu's Export Results both +/// build their payload here, so one label cannot mean two files. +pub(crate) fn export_snapshot( + column_view: >k::ColumnView, + columns: &[ResultColumn], + truncated: bool, + ctx: &TabGridContext, +) -> QueryResult { + let rows = match column_view.model() { + Some(model) => (0..model.n_items()) + .filter_map(|p| row_at(column_view, p)) + .map(|r| ctx.effective_cells(&r)) + .collect(), + None => Vec::new(), + }; + QueryResult::new(columns.to_vec(), rows).truncated(truncated) +} + +pub(crate) fn focused_cell_coords(widget: &impl IsA) -> Option<(u32, usize)> { + let root = widget.root()?; + let window = root.dynamic_cast::().ok()?; + // `RootExt::focus` would return the window itself. + let focused = gtk4::prelude::GtkWindowExt::focus(&window)?; + let position = POSITION_SLOT.get(&focused)?; + let column = COLUMN_SLOT.get(&focused)?; + Some((position, column)) +} diff --git a/linux/crates/app/src/ui/grid/tab_grid_context.rs b/linux/crates/app/src/ui/grid/tab_grid_context.rs new file mode 100644 index 0000000000..4fd4064aa1 --- /dev/null +++ b/linux/crates/app/src/ui/grid/tab_grid_context.rs @@ -0,0 +1,110 @@ +use tablepro_core::Value; + +use super::{ColumnWidthBinding, RowObject}; +use crate::services::cell_overlay::CellOverlay; +use crate::services::change_tracker::{CellState, RowKey, RowState}; +use crate::services::tab_tracker_handle::TabTrackerHandle; + +/// `tracker` and `column_widths` are `None` for a grid nothing tracks, +/// such as editor results. +#[derive(Debug, Clone, Default)] +pub struct TabGridContext { + pub tracker: Option, + pub pk_col_indices: Vec, + pub column_widths: Option, +} + +impl TabGridContext { + /// `None` when nothing tracks the grid or a persisted row has no + /// usable primary key. + fn tracked_key(&self, row: &RowObject) -> Option<(&TabTrackerHandle, RowKey)> { + let tracker = self.tracker.as_ref()?; + let key = match row.draft_id() { + Some(draft_id) => RowKey::Draft(draft_id), + None => { + let pk_values: Vec = self.pk_col_indices.iter().map(|&i| row.cell_value(i)).collect(); + RowKey::from_pk_values(&pk_values)? + } + }; + Some((tracker, key)) + } + + /// Every copy and export path reads this rather than + /// `RowObject::cells_clone`, which holds a persisted row as fetched. + pub(crate) fn effective_cells(&self, row: &RowObject) -> Vec { + let raw = row.cells_clone(); + let Some((tracker, key)) = self.tracked_key(row) else { + return raw; + }; + raw.iter() + .enumerate() + .map(|(col, value)| tracker.cell_overlay(&key, col, value).value) + .collect() + } + + pub(super) fn bind_state(&self, row: &RowObject, col: usize) -> (Value, Vec<&'static str>) { + let raw = row.cell_value(col); + let Some((tracker, key)) = self.tracked_key(row) else { + return (raw, Vec::new()); + }; + let overlay = tracker.cell_overlay(&key, col, &raw); + let classes = pending_classes(tracker.row_state(&key), &overlay, col); + (overlay.value, classes) + } +} + +fn pending_classes(row_state: RowState, overlay: &CellOverlay, col: usize) -> Vec<&'static str> { + let mut classes = Vec::new(); + match (row_state, overlay.state) { + (RowState::PendingDelete, _) => classes.push("tp-row-pending-delete"), + (RowState::InsertDraft, _) => classes.push("tp-row-pending-insert"), + (_, CellState::Modified) => classes.push("tp-cell-modified"), + _ => {} + } + if col == 0 && overlay.error_flash { + classes.push("tp-row-leftmost-error-flash"); + } + classes +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + + use super::{RowObject, TabGridContext}; + use crate::services::change_tracker::RowKey; + use crate::services::tab_tracker_handle::TabTrackerHandle; + + #[test] + fn a_tracked_draft_carries_its_insert_tint_and_error_flash() { + let tracker = TabTrackerHandle::default(); + let delta = tracker.apply(crate::services::tracker_edit::TrackerEdit::Insert { + values: vec![Value::Null], + }); + let Some(crate::services::tracker_delta::VisualChange::InsertDraft { draft_id, values }) = + delta.visual.into_iter().next() + else { + panic!("an insert adds a draft row"); + }; + let _ = tracker.flash_error_row(RowKey::Draft(draft_id)); + let context = TabGridContext { + tracker: Some(tracker), + pk_col_indices: vec![0], + column_widths: None, + }; + + let (_, classes) = context.bind_state(&RowObject::new_draft(draft_id, values), 0); + + assert_eq!(classes, vec!["tp-row-pending-insert", "tp-row-leftmost-error-flash"]); + } + + #[test] + fn an_untracked_grid_draws_the_fetched_value_plainly() { + let row = RowObject::new(vec![Value::Int(1)]); + + let (value, classes) = TabGridContext::default().bind_state(&row, 0); + + assert_eq!(value, Value::Int(1)); + assert!(classes.is_empty()); + } +} diff --git a/linux/crates/app/src/ui/grid/widget_slot.rs b/linux/crates/app/src/ui/grid/widget_slot.rs new file mode 100644 index 0000000000..57f8852d38 --- /dev/null +++ b/linux/crates/app/src/ui/grid/widget_slot.rs @@ -0,0 +1,46 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::inline_edit::EditSnapshot; + +pub(super) struct WidgetSlot { + key: &'static str, + _phantom: std::marker::PhantomData, +} + +impl WidgetSlot { + const fn new(key: &'static str) -> Self { + Self { + key, + _phantom: std::marker::PhantomData, + } + } + + pub(super) fn set(&self, widget: &impl IsA, value: T) { + unsafe { widget.set_data(self.key, value) }; + } + + pub(super) fn take(&self, widget: &impl IsA) -> Option { + unsafe { widget.steal_data::(self.key) } + } +} + +impl WidgetSlot { + pub(super) fn get(&self, widget: &impl IsA) -> Option { + unsafe { widget.data::(self.key).map(|p| *p.as_ref()) } + } +} + +pub(super) const POSITION_SLOT: WidgetSlot = WidgetSlot::new("tp-position"); +pub(super) const SNAPSHOT_SLOT: WidgetSlot = WidgetSlot::new("tp-snapshot"); +pub(super) const COLUMN_SLOT: WidgetSlot = WidgetSlot::new("tp-column"); +/// Set while the bind drives a check button, which would otherwise echo +/// back as a CellEdited of the value it just drew. +pub(super) const SUPPRESS_SLOT: WidgetSlot = WidgetSlot::new("tp-suppress-toggle"); +/// The cell's open editor popover. Unbind pops it down, or the recycled +/// cell is finalized with a GtkPopover child still in it. +pub(super) const POPOVER_SLOT: WidgetSlot = WidgetSlot::new("tp-popover"); +/// Set while an IME composition is in flight: focus moving to the IME +/// popover ends the edit, and committing then would send the text from +/// before the composition. +pub(super) const PREEDIT_SLOT: WidgetSlot = WidgetSlot::new("tp-preedit-active"); diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 7bb88ee598..93d077658f 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -1,6 +1,5 @@ mod app; mod browse_tab; -mod cell_editor; mod connect_dialog; mod connection_list; mod connection_list_banner; @@ -18,7 +17,6 @@ mod history_dialog; pub(crate) mod icons; mod import_dialog; mod preferences_dialog; -mod row_object; mod saved_queries_dialog; mod sidebar_row; mod ssh_inputs; diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 11581bdbfc..d69b90518f 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -68,7 +68,6 @@ crates/app/src/ui/browse_tab/imp.rs crates/app/src/ui/browse_tab/mod.rs crates/app/src/ui/browse_tab/page_reply.rs crates/app/src/ui/browse_tab/tests.rs -crates/app/src/ui/cell_editor.rs crates/app/src/ui/connect_dialog/connect_form.rs crates/app/src/ui/connect_dialog/connect_submission.rs crates/app/src/ui/connect_dialog/form_errors.rs @@ -120,7 +119,23 @@ crates/app/src/ui/error_text/tls_remedy.rs crates/app/src/ui/export_dialog.rs crates/app/src/ui/file_read.rs crates/app/src/ui/filter_strip.rs -crates/app/src/ui/grid.rs +crates/app/src/ui/grid/cell_editor.rs +crates/app/src/ui/grid/cell_editor_kind.rs +crates/app/src/ui/grid/cell_popovers.rs +crates/app/src/ui/grid/cell_preset.rs +crates/app/src/ui/grid/column_factory.rs +crates/app/src/ui/grid/column_width_binding.rs +crates/app/src/ui/grid/context_menu.rs +crates/app/src/ui/grid/display_text.rs +crates/app/src/ui/grid/grid_menus.rs +crates/app/src/ui/grid/grid_msg.rs +crates/app/src/ui/grid/inline_edit.rs +crates/app/src/ui/grid/mod.rs +crates/app/src/ui/grid/row_json_dialog.rs +crates/app/src/ui/grid/row_object.rs +crates/app/src/ui/grid/selection.rs +crates/app/src/ui/grid/tab_grid_context.rs +crates/app/src/ui/grid/widget_slot.rs crates/app/src/ui/handler_guard.rs crates/app/src/ui/history_dialog.rs crates/app/src/ui/icons.rs @@ -128,7 +143,6 @@ crates/app/src/ui/import_dialog.rs crates/app/src/ui/mod.rs crates/app/src/ui/preferences_dialog/imp.rs crates/app/src/ui/preferences_dialog/mod.rs -crates/app/src/ui/row_object.rs crates/app/src/ui/saved_queries_dialog.rs crates/app/src/ui/sidebar_row.rs crates/app/src/ui/ssh_inputs.rs From bfd3fc4cc6edbc8962f3cd99217b67644dd6c8ce Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 22:49:22 +0700 Subject: [PATCH 104/178] refactor(linux): add a per-session workspace store with a writer task --- linux/crates/app/src/storage.rs | 8 + linux/crates/app/src/workspace/mod.rs | 17 + .../app/src/workspace/persist_debouncer.rs | 22 + linux/crates/app/src/workspace/sort_record.rs | 8 + linux/crates/app/src/workspace/tab_record.rs | 110 ++++ .../app/src/workspace/workspace_file_name.rs | 76 +++ .../app/src/workspace/workspace_load.rs | 11 + .../app/src/workspace/workspace_load_error.rs | 35 ++ .../app/src/workspace/workspace_record.rs | 136 +++++ .../app/src/workspace/workspace_save_error.rs | 14 + .../app/src/workspace/workspace_store.rs | 487 ++++++++++++++++++ linux/crates/app/src/workspace/write_job.rs | 19 + 12 files changed, 943 insertions(+) create mode 100644 linux/crates/app/src/workspace/sort_record.rs create mode 100644 linux/crates/app/src/workspace/tab_record.rs create mode 100644 linux/crates/app/src/workspace/workspace_file_name.rs create mode 100644 linux/crates/app/src/workspace/workspace_load.rs create mode 100644 linux/crates/app/src/workspace/workspace_load_error.rs create mode 100644 linux/crates/app/src/workspace/workspace_record.rs create mode 100644 linux/crates/app/src/workspace/workspace_save_error.rs create mode 100644 linux/crates/app/src/workspace/workspace_store.rs create mode 100644 linux/crates/app/src/workspace/write_job.rs diff --git a/linux/crates/app/src/storage.rs b/linux/crates/app/src/storage.rs index 7ecbb7c387..3c9ec10b98 100644 --- a/linux/crates/app/src/storage.rs +++ b/linux/crates/app/src/storage.rs @@ -5,6 +5,7 @@ use tablepro_session::runtime::Tasks; use tablepro_storage::{ConnectionStore, StoragePaths}; use crate::persistence::{ColumnWidthStore, FilterSettingsStore}; +use crate::workspace::WorkspaceStore; /// Everything the app persists, resolved once at startup and handed to /// the root component. Nothing reaches for a global, so a test can point @@ -16,6 +17,7 @@ pub struct AppStorage { secrets: std::sync::Arc, column_widths: ColumnWidthStore, filter_settings: FilterSettingsStore, + workspaces: WorkspaceStore, } impl AppStorage { @@ -25,12 +27,14 @@ impl AppStorage { let connections = ConnectionStore::new(&paths); let column_widths = ColumnWidthStore::load(paths.column_widths_file(), tasks); let filter_settings = FilterSettingsStore::load(paths.filter_settings_file(), tasks); + let workspaces = WorkspaceStore::start(paths.workspaces_dir(), tasks); Self { paths, connections, secrets, column_widths, filter_settings, + workspaces, } } @@ -53,6 +57,10 @@ impl AppStorage { pub fn filter_settings(&self) -> &FilterSettingsStore { &self.filter_settings } + + pub fn workspaces(&self) -> &WorkspaceStore { + &self.workspaces + } } pub type SharedStorage = Rc; diff --git a/linux/crates/app/src/workspace/mod.rs b/linux/crates/app/src/workspace/mod.rs index 9fcb30942e..b9f3b24841 100644 --- a/linux/crates/app/src/workspace/mod.rs +++ b/linux/crates/app/src/workspace/mod.rs @@ -3,6 +3,23 @@ mod draft_writer; mod persist_debouncer; +mod sort_record; +mod tab_record; +mod workspace_file_name; +mod workspace_load; +mod workspace_load_error; +mod workspace_record; +mod workspace_save_error; +mod workspace_store; +mod write_job; pub use draft_writer::DraftWriter; pub use persist_debouncer::PersistDebouncer; +pub use sort_record::SortRecord; +pub use tab_record::TabRecord; +pub use workspace_file_name::WorkspaceFileName; +pub use workspace_load::WorkspaceLoad; +pub use workspace_load_error::WorkspaceLoadError; +pub use workspace_record::WorkspaceRecord; +pub use workspace_save_error::WorkspaceSaveError; +pub use workspace_store::WorkspaceStore; diff --git a/linux/crates/app/src/workspace/persist_debouncer.rs b/linux/crates/app/src/workspace/persist_debouncer.rs index bef1c8d5d3..d46900e94a 100644 --- a/linux/crates/app/src/workspace/persist_debouncer.rs +++ b/linux/crates/app/src/workspace/persist_debouncer.rs @@ -76,6 +76,28 @@ mod tests { assert_eq!(fires.get(), 1, "a run of requests fired more than once"); } + #[gtk4::test] + fn request_after_fire_emits_no_critical() { + // What g_test_init does: removing a source that already fired + // logs a critical, which then aborts the test. + let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL | glib::LogLevels::LEVEL_WARNING); + let debouncer = PersistDebouncer::new(Duration::from_millis(20)); + let fires = Rc::new(Cell::new(0)); + + for expected in 1..=2 { + let fires_for_callback = fires.clone(); + debouncer.request(move || fires_for_callback.set(fires_for_callback.get() + 1)); + let fired = crate::test_support::wait_until(Duration::from_secs(2), || fires.get() == expected); + if fired.is_err() { + glib::log_set_always_fatal(previous); + } + fired.expect("the callback never ran"); + } + glib::log_set_always_fatal(previous); + + assert_eq!(fires.get(), 2); + } + #[gtk4::test] fn cancel_stops_a_pending_callback() { let debouncer = PersistDebouncer::new(Duration::from_millis(20)); diff --git a/linux/crates/app/src/workspace/sort_record.rs b/linux/crates/app/src/workspace/sort_record.rs new file mode 100644 index 0000000000..f33f4cb370 --- /dev/null +++ b/linux/crates/app/src/workspace/sort_record.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +/// The column a table tab was sorted by, as its position in the result. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SortRecord { + pub column: usize, + pub ascending: bool, +} diff --git a/linux/crates/app/src/workspace/tab_record.rs b/linux/crates/app/src/workspace/tab_record.rs new file mode 100644 index 0000000000..ab70c77a44 --- /dev/null +++ b/linux/crates/app/src/workspace/tab_record.rs @@ -0,0 +1,110 @@ +use std::ops::RangeInclusive; + +use serde::{Deserialize, Serialize}; +use tablepro_storage::DraftId; + +use super::SortRecord; + +/// What the page size preference allows. +const PAGE_SIZES: RangeInclusive = 100..=100_000; +const DEFAULT_PAGE_SIZE: u64 = 1_000; +/// No engine allows a name this long, so a record with one names no +/// table there is to reopen. +const MAX_NAME_BYTES: usize = 256; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TabRecord { + Table { + schema: Option, + table: String, + offset: u64, + page_size: u64, + sort: Option, + }, + Structure { + schema: Option, + table: String, + }, + /// The text lives in the draft store, so a script of any length + /// costs the record one id. + Editor { + draft: DraftId, + }, +} + +impl TabRecord { + /// The record as a tab can be built from it, or `None` when it names + /// nothing that could exist. + pub(super) fn clamped(self) -> Option { + match self { + Self::Table { + schema, + table, + offset, + page_size, + sort, + } => { + if !names_fit(schema.as_deref(), &table) { + return None; + } + let page_size = if PAGE_SIZES.contains(&page_size) { + page_size + } else { + DEFAULT_PAGE_SIZE + }; + Some(Self::Table { + schema, + table, + offset, + page_size, + sort, + }) + } + Self::Structure { schema, table } => { + names_fit(schema.as_deref(), &table).then_some(Self::Structure { schema, table }) + } + Self::Editor { draft } => Some(Self::Editor { draft }), + } + } +} + +fn names_fit(schema: Option<&str>, table: &str) -> bool { + !table.is_empty() && table.len() <= MAX_NAME_BYTES && schema.is_none_or(|schema| schema.len() <= MAX_NAME_BYTES) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn table(name: &str, page_size: u64) -> TabRecord { + TabRecord::Table { + schema: Some("public".to_owned()), + table: name.to_owned(), + offset: 0, + page_size, + sort: None, + } + } + + #[test] + fn a_page_size_outside_the_preference_range_falls_back_to_the_default() { + let Some(TabRecord::Table { page_size, .. }) = table("users", 999_999).clamped() else { + panic!("the table record was dropped"); + }; + + assert_eq!(page_size, DEFAULT_PAGE_SIZE); + assert_eq!(table("users", 250).clamped(), Some(table("users", 250))); + } + + #[test] + fn a_record_naming_no_possible_table_is_dropped() { + assert_eq!(table("", 1_000).clamped(), None); + assert_eq!(table(&"t".repeat(MAX_NAME_BYTES + 1), 1_000).clamped(), None); + let structure = TabRecord::Structure { + schema: Some("s".repeat(MAX_NAME_BYTES + 1)), + table: "users".to_owned(), + }; + assert_eq!(structure.clamped(), None); + } +} diff --git a/linux/crates/app/src/workspace/workspace_file_name.rs b/linux/crates/app/src/workspace/workspace_file_name.rs new file mode 100644 index 0000000000..79127d7327 --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_file_name.rs @@ -0,0 +1,76 @@ +use tablepro_session::SessionKey; +use tablepro_storage::DraftScope; + +/// The name one session's saved tabs are kept under, which also names +/// the directory its editor drafts live in. +/// +/// A file key is hashed: its URI can be longer than a file name may be, +/// and holds slashes. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WorkspaceFileName { + stem: String, +} + +impl WorkspaceFileName { + pub fn for_key(key: &SessionKey) -> Self { + let stem = match key { + SessionKey::Saved(id) => id.to_string(), + SessionKey::File(uri) => { + // GLib returns no digest only for a checksum type it + // does not know, which SHA-256 is not. + let digest = glib::compute_checksum_for_string(glib::ChecksumType::Sha256, uri.as_str()) + .map(|digest| digest.to_string()) + .unwrap_or_default(); + format!("file-{digest}") + } + }; + Self { stem } + } + + pub fn file_name(&self) -> String { + format!("{}.json", self.stem) + } + + pub fn draft_scope(&self) -> DraftScope { + DraftScope::new(&self.stem).unwrap_or_else(|error| { + tracing::warn!(%error, "a workspace name is not a usable draft scope"); + DraftScope::default() + }) + } +} + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::*; + + #[test] + fn names_for_saved_and_file_keys() { + let id = Uuid::parse_str("4f0c6a52-3b7e-4d55-9a3c-2a8f3f7e9b10").expect("a uuid"); + + let saved = WorkspaceFileName::for_key(&SessionKey::Saved(id)); + let file = WorkspaceFileName::for_key(&SessionKey::File("file:///tmp/app.db".to_owned())); + + assert_eq!(saved.file_name(), "4f0c6a52-3b7e-4d55-9a3c-2a8f3f7e9b10.json"); + assert_eq!( + file.file_name(), + "file-35a613072ab5ce7e8aff29b0edfd55feeaff4c9c99b6df5572945f4bc16a71f7.json" + ); + assert_ne!( + file, + WorkspaceFileName::for_key(&SessionKey::File("file:///tmp/other.db".to_owned())) + ); + } + + #[test] + fn each_name_is_its_own_draft_scope() { + let id = Uuid::new_v4(); + let saved = WorkspaceFileName::for_key(&SessionKey::Saved(id)); + let file = WorkspaceFileName::for_key(&SessionKey::File("file:///tmp/app.db".to_owned())); + + assert_eq!(saved.draft_scope().as_str(), id.to_string()); + assert!(file.draft_scope().as_str().starts_with("file-")); + assert_ne!(saved.draft_scope(), DraftScope::default()); + } +} diff --git a/linux/crates/app/src/workspace/workspace_load.rs b/linux/crates/app/src/workspace/workspace_load.rs new file mode 100644 index 0000000000..4fe4fb534d --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_load.rs @@ -0,0 +1,11 @@ +use super::{WorkspaceLoadError, WorkspaceRecord}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceLoad { + Restored(WorkspaceRecord), + /// Nothing was saved for this session yet. + Empty, + /// There is a file, and it cannot be used. The store refuses to + /// write over it until it is reset. + Blocked(WorkspaceLoadError), +} diff --git a/linux/crates/app/src/workspace/workspace_load_error.rs b/linux/crates/app/src/workspace/workspace_load_error.rs new file mode 100644 index 0000000000..7b29786a62 --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_load_error.rs @@ -0,0 +1,35 @@ +use tablepro_storage::document::VersionedDocument; +use tablepro_storage::{DocumentProblem, DocumentProblemKind}; +use thiserror::Error; + +use super::WorkspaceRecord; + +/// Why a session's saved tabs could not be used. The file is left as it +/// is in every case, so a fix or an upgrade can still bring them back. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum WorkspaceLoadError { + #[error("the saved tabs could not be read: {0}")] + Io(String), + #[error("the saved tabs are not valid: {0}")] + Corrupt(String), + #[error("the saved tabs are in a format this version does not read (found {found:?}, reads {expected})")] + UnsupportedVersion { found: Option, expected: u32 }, +} + +impl From for WorkspaceLoadError { + fn from(problem: DocumentProblem) -> Self { + match problem.kind { + DocumentProblemKind::Unreadable { detail } => Self::Io(detail), + DocumentProblemKind::Corrupt { detail, .. } => Self::Corrupt(detail), + DocumentProblemKind::MissingVersion => Self::UnsupportedVersion { + found: None, + expected: WorkspaceRecord::VERSION, + }, + DocumentProblemKind::UnsupportedVersion { found, expected } + | DocumentProblemKind::NewerVersion { found, expected } => Self::UnsupportedVersion { + found: Some(found), + expected, + }, + } + } +} diff --git a/linux/crates/app/src/workspace/workspace_record.rs b/linux/crates/app/src/workspace/workspace_record.rs new file mode 100644 index 0000000000..0334d2e502 --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_record.rs @@ -0,0 +1,136 @@ +use serde::{Deserialize, Serialize}; +use tablepro_storage::document::VersionedDocument; + +use super::TabRecord; + +/// More tabs than this is a file to distrust, not a window to rebuild. +const MAX_TABS: usize = 32; + +/// One session's saved tabs, in the order the window showed them. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceRecord { + /// An index into `tabs`, not into the window's pages: a tab that is + /// not saved has no record to point at. + #[serde(default)] + pub active_tab: Option, + #[serde(default)] + pub tabs: Vec, +} + +impl VersionedDocument for WorkspaceRecord { + const KIND: &'static str = "workspace"; + const VERSION: u32 = 1; +} + +impl WorkspaceRecord { + /// The record as a window can rebuild it: at most `MAX_TABS` tabs, + /// none naming something that could not exist, and `active_tab` + /// still pointing at the tab it named, or at none. + pub fn clamped(self) -> Self { + let active = self.active_tab.and_then(|index| usize::try_from(index).ok()); + let mut tabs = Vec::with_capacity(self.tabs.len().min(MAX_TABS)); + let mut active_tab = None; + for (index, tab) in self.tabs.into_iter().enumerate() { + if tabs.len() == MAX_TABS { + break; + } + let Some(tab) = tab.clamped() else { + continue; + }; + if active == Some(index) { + active_tab = u32::try_from(tabs.len()).ok(); + } + tabs.push(tab); + } + Self { active_tab, tabs } + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use tablepro_storage::DraftId; + use tablepro_storage::document::{decode_document, encode_document}; + + use super::*; + use crate::workspace::SortRecord; + + fn table(name: &str) -> TabRecord { + TabRecord::Table { + schema: Some("public".to_owned()), + table: name.to_owned(), + offset: 0, + page_size: 1_000, + sort: None, + } + } + + #[test] + fn round_trip() { + let record = WorkspaceRecord { + active_tab: Some(1), + tabs: vec![ + TabRecord::Table { + schema: Some("public".to_owned()), + table: "orders".to_owned(), + offset: 5_000, + page_size: 5_000, + sort: Some(SortRecord { + column: 2, + ascending: false, + }), + }, + TabRecord::Structure { + schema: None, + table: "users".to_owned(), + }, + TabRecord::Editor { draft: DraftId::new() }, + ], + }; + + let bytes = encode_document(&record).expect("encode"); + let decoded: WorkspaceRecord = decode_document(Path::new("/tmp/w.json"), &bytes).expect("decode"); + + assert_eq!(decoded, record); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("\"version\": 1"), "{text}"); + assert!(!text.contains("SELECT"), "an editor record carries text: {text}"); + } + + #[test] + fn clamped_limits_tabs_and_resets_active_tab() { + let record = WorkspaceRecord { + active_tab: Some(35), + tabs: (0..40).map(|index| table(&format!("t{index}"))).collect(), + }; + + let clamped = record.clamped(); + + assert_eq!(clamped.tabs.len(), MAX_TABS); + assert_eq!(clamped.active_tab, None); + } + + #[test] + fn clamped_keeps_active_tab_on_the_tab_it_named() { + let record = WorkspaceRecord { + active_tab: Some(2), + tabs: vec![table("a"), table(""), table("c")], + }; + + let clamped = record.clamped(); + + assert_eq!(clamped.tabs, vec![table("a"), table("c")]); + assert_eq!(clamped.active_tab, Some(1)); + } + + #[test] + fn clamped_drops_an_active_tab_that_named_a_dropped_record() { + let record = WorkspaceRecord { + active_tab: Some(1), + tabs: vec![table("a"), table("")], + }; + + assert_eq!(record.clamped().active_tab, None); + } +} diff --git a/linux/crates/app/src/workspace/workspace_save_error.rs b/linux/crates/app/src/workspace/workspace_save_error.rs new file mode 100644 index 0000000000..b424d7ed80 --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_save_error.rs @@ -0,0 +1,14 @@ +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum WorkspaceSaveError { + /// The saved tabs could not be read, and writing would destroy them. + #[error("the saved tabs could not be read, so they are not written over")] + Blocked, + #[error("could not save the open tabs: {0}")] + Write(String), + #[error("could not move the saved tabs aside: {0}")] + MoveAside(String), + #[error("the workspace writer has stopped")] + WriterStopped, +} diff --git a/linux/crates/app/src/workspace/workspace_store.rs b/linux/crates/app/src/workspace/workspace_store.rs new file mode 100644 index 0000000000..ecb0ac5781 --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_store.rs @@ -0,0 +1,487 @@ +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::Arc; + +use tablepro_session::SessionKey; +use tablepro_session::runtime::Tasks; +use tablepro_storage::StorageError; +use tablepro_storage::document::{decode_document, encode_document}; +use tokio::sync::{mpsc, oneshot}; + +use super::write_job::WriteJob; +use super::{WorkspaceFileName, WorkspaceLoad, WorkspaceLoadError, WorkspaceRecord, WorkspaceSaveError}; + +type WriteFile = dyn Fn(&Path, &[u8]) -> Result<(), StorageError> + Send + Sync; + +/// Every session's saved tabs, one file each. +/// +/// Writes go to one task that keeps only the newest record per file, so +/// a burst of tab changes costs one write, and each write runs on a +/// blocking thread. A file that could not be read is never written over +/// until the user resets it. +#[derive(Clone)] +pub struct WorkspaceStore { + inner: Rc, +} + +struct StoreInner { + dir: PathBuf, + tasks: Tasks, + writer: mpsc::UnboundedSender, + blocked: RefCell>, +} + +impl WorkspaceStore { + pub fn start(dir: PathBuf, tasks: &Tasks) -> Self { + Self::start_with(dir, tasks, Arc::new(tablepro_storage::fs::write_private_blocking)) + } + + fn start_with(dir: PathBuf, tasks: &Tasks, write: Arc) -> Self { + let (writer, jobs) = mpsc::unbounded_channel(); + // Detached, so the last store going closes the channel and the + // task ends once what it was handed is written. + drop(tasks.spawn_task(run_writer(jobs, tasks.clone(), write))); + Self { + inner: Rc::new(StoreInner { + dir, + tasks: tasks.clone(), + writer, + blocked: RefCell::default(), + }), + } + } + + /// Read a session's saved tabs, after any write already queued for + /// it has landed. + pub fn load(&self, key: &SessionKey) -> impl Future + use<> { + let inner = self.inner.clone(); + let key = key.clone(); + let path = self.inner.path(&key); + let written = self.inner.queue_flush(&path); + async move { + // A failed write still leaves whatever is on disk to read. + let _ = written.await; + let reading = path.clone(); + let load = inner + .tasks + .spawn_blocking_task(move || read_record(&reading)) + .await + .unwrap_or_else(|failure| WorkspaceLoad::Blocked(WorkspaceLoadError::Io(failure.to_string()))); + match &load { + WorkspaceLoad::Blocked(error) => { + tracing::warn!(%error, path = %path.display(), "keeping the saved tabs as they are"); + inner.blocked.borrow_mut().insert(key); + } + WorkspaceLoad::Restored(_) | WorkspaceLoad::Empty => { + inner.blocked.borrow_mut().remove(&key); + } + } + load + } + } + + /// Queue `record` as the session's saved tabs. A newer record for the + /// same session replaces it if the writer has not reached it yet. + pub fn schedule(&self, key: &SessionKey, record: WorkspaceRecord) -> Result<(), WorkspaceSaveError> { + if self.is_blocked(key) { + return Err(WorkspaceSaveError::Blocked); + } + self.inner + .writer + .send(WriteJob::Write { + path: self.inner.path(key), + record, + }) + .map_err(|_| WorkspaceSaveError::WriterStopped) + } + + /// Resolves once every record queued so far for the session is on + /// disk, with how the last write of it went. + pub fn flush(&self, key: &SessionKey) -> impl Future> + use<> { + let written = (!self.is_blocked(key)).then(|| self.inner.queue_flush(&self.inner.path(key))); + async move { + let Some(written) = written else { + return Err(WorkspaceSaveError::Blocked); + }; + written.await.unwrap_or(Err(WorkspaceSaveError::WriterStopped)) + } + } + + /// Move a file that could not be read aside, bytes and all, and let + /// the session save again. Gives the name the file now has, or `None` + /// when there was no file. + pub fn reset(&self, key: &SessionKey) -> impl Future, WorkspaceSaveError>> + use<> { + let inner = self.inner.clone(); + let key = key.clone(); + let path = self.inner.path(&key); + async move { + let moved = inner + .tasks + .spawn_blocking_task(move || move_aside(&path)) + .await + .unwrap_or_else(|failure| Err(WorkspaceSaveError::MoveAside(failure.to_string())))?; + inner.blocked.borrow_mut().remove(&key); + Ok(moved) + } + } + + pub fn is_blocked(&self, key: &SessionKey) -> bool { + self.inner.blocked.borrow().contains(key) + } +} + +impl StoreInner { + fn path(&self, key: &SessionKey) -> PathBuf { + self.dir.join(WorkspaceFileName::for_key(key).file_name()) + } + + fn queue_flush(&self, path: &Path) -> oneshot::Receiver> { + let (written, receiver) = oneshot::channel(); + // A writer that has stopped drops the job, and with it the + // sender, which the receiver reports. + let _ = self.writer.send(WriteJob::Flush { + path: path.to_owned(), + written, + }); + receiver + } +} + +/// The jobs that arrived while the writer was busy. Only the newest +/// record per file is kept, and every waiter is answered after the +/// writes it queued behind. +#[derive(Default)] +struct Round { + records: BTreeMap, + waiting: Vec<(PathBuf, oneshot::Sender>)>, +} + +impl Round { + fn take(&mut self, job: WriteJob) { + match job { + WriteJob::Write { path, record } => { + self.records.insert(path, record); + } + WriteJob::Flush { path, written } => self.waiting.push((path, written)), + } + } +} + +async fn run_writer(mut jobs: mpsc::UnboundedReceiver, tasks: Tasks, write: Arc) { + let mut last: HashMap> = HashMap::new(); + while let Some(job) = jobs.recv().await { + let mut round = Round::default(); + round.take(job); + while let Ok(job) = jobs.try_recv() { + round.take(job); + } + for (path, record) in round.records { + let outcome = write_record(&tasks, &write, path.clone(), record).await; + last.insert(path, outcome); + } + for (path, written) in round.waiting { + let _ = written.send(last.get(&path).cloned().unwrap_or(Ok(()))); + } + } +} + +async fn write_record( + tasks: &Tasks, + write: &Arc, + path: PathBuf, + record: WorkspaceRecord, +) -> Result<(), WorkspaceSaveError> { + let write = write.clone(); + let shown = path.display().to_string(); + let outcome = tasks + .spawn_blocking_task(move || { + let bytes = encode_document(&record).map_err(|error| WorkspaceSaveError::Write(error.to_string()))?; + write(&path, &bytes).map_err(|error| WorkspaceSaveError::Write(error.to_string())) + }) + .await + .unwrap_or_else(|failure| Err(WorkspaceSaveError::Write(failure.to_string()))); + if let Err(error) = &outcome { + tracing::warn!(%error, path = %shown, "could not save the open tabs"); + } + outcome +} + +fn read_record(path: &Path) -> WorkspaceLoad { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == ErrorKind::NotFound => return WorkspaceLoad::Empty, + Err(error) => return WorkspaceLoad::Blocked(WorkspaceLoadError::Io(error.to_string())), + }; + match decode_document::(path, &bytes) { + Ok(record) => WorkspaceLoad::Restored(record.clamped()), + Err(problem) => WorkspaceLoad::Blocked(problem.into()), + } +} + +fn move_aside(path: &Path) -> Result, WorkspaceSaveError> { + if let Err(error) = std::fs::symlink_metadata(path) + && error.kind() == ErrorKind::NotFound + { + return Ok(None); + } + tablepro_storage::fs::move_aside_blocking(path, std::time::SystemTime::now()) + .map(Some) + .map_err(|error| WorkspaceSaveError::MoveAside(error.to_string())) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + use std::sync::Mutex; + + use tablepro_storage::DraftId; + use uuid::Uuid; + + use super::*; + use crate::test_support::paused_tasks; + use crate::workspace::TabRecord; + + fn key() -> SessionKey { + SessionKey::Saved(Uuid::new_v4()) + } + + fn record(tables: usize) -> WorkspaceRecord { + WorkspaceRecord { + active_tab: Some(0), + tabs: (0..tables) + .map(|index| TabRecord::Table { + schema: None, + table: format!("t{index}"), + offset: 0, + page_size: 1_000, + sort: None, + }) + .collect(), + } + } + + struct Fixture { + root: tempfile::TempDir, + store: WorkspaceStore, + } + + impl Fixture { + fn new() -> Self { + let root = tempfile::tempdir().expect("tempdir"); + let store = WorkspaceStore::start(root.path().join("workspaces"), &paused_tasks()); + Self { root, store } + } + + fn path(&self, key: &SessionKey) -> PathBuf { + self.root + .path() + .join("workspaces") + .join(WorkspaceFileName::for_key(key).file_name()) + } + + fn seed(&self, key: &SessionKey, bytes: &[u8]) -> PathBuf { + let path = self.path(key); + std::fs::create_dir_all(path.parent().expect("a parent")).expect("mkdir"); + std::fs::write(&path, bytes).expect("seed"); + path + } + + fn on_disk(&self, key: &SessionKey) -> WorkspaceRecord { + let path = self.path(key); + let bytes = std::fs::read(&path).expect("the file was written"); + decode_document(&path, &bytes).expect("a record") + } + } + + #[tokio::test] + async fn notfound_loads_empty() { + let fixture = Fixture::new(); + let key = key(); + + assert_eq!(fixture.store.load(&key).await, WorkspaceLoad::Empty); + assert!(!fixture.store.is_blocked(&key)); + } + + #[tokio::test] + async fn flush_waits_for_pending_write() { + let fixture = Fixture::new(); + let key = key(); + + fixture.store.schedule(&key, record(2)).expect("scheduled"); + fixture.store.flush(&key).await.expect("written"); + + assert_eq!(fixture.on_disk(&key), record(2)); + } + + #[tokio::test] + async fn load_reads_a_record_still_queued() { + let fixture = Fixture::new(); + let key = key(); + let editor = WorkspaceRecord { + active_tab: Some(0), + tabs: vec![TabRecord::Editor { draft: DraftId::new() }], + }; + + fixture.store.schedule(&key, editor.clone()).expect("scheduled"); + + assert_eq!(fixture.store.load(&key).await, WorkspaceLoad::Restored(editor)); + } + + #[tokio::test] + async fn written_file_mode_0600() { + let fixture = Fixture::new(); + let key = key(); + + fixture.store.schedule(&key, record(1)).expect("scheduled"); + fixture.store.flush(&key).await.expect("written"); + + let mode = std::fs::metadata(fixture.path(&key)) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[tokio::test] + async fn unsupported_version_blocks_key() { + let fixture = Fixture::new(); + let key = key(); + fixture.seed(&key, br#"{"version": 2, "tabs": []}"#); + + let load = fixture.store.load(&key).await; + + assert_eq!( + load, + WorkspaceLoad::Blocked(WorkspaceLoadError::UnsupportedVersion { + found: Some(2), + expected: 1 + }) + ); + assert_eq!( + fixture.store.schedule(&key, record(1)), + Err(WorkspaceSaveError::Blocked) + ); + } + + #[tokio::test] + async fn blocked_key_leaves_bytes_unchanged() { + let fixture = Fixture::new(); + let (key, other) = (key(), key()); + let original = b"{ not json"; + let path = fixture.seed(&key, original); + + let load = fixture.store.load(&key).await; + let scheduled = fixture.store.schedule(&key, record(1)); + let flushed = fixture.store.flush(&key).await; + fixture + .store + .schedule(&other, record(1)) + .expect("another key still saves"); + fixture.store.flush(&other).await.expect("written"); + + assert!( + matches!(load, WorkspaceLoad::Blocked(WorkspaceLoadError::Corrupt(_))), + "{load:?}" + ); + assert_eq!(scheduled, Err(WorkspaceSaveError::Blocked)); + assert_eq!(flushed, Err(WorkspaceSaveError::Blocked)); + assert_eq!(std::fs::read(&path).expect("read"), original); + } + + #[tokio::test] + async fn reset_moves_aside_and_unblocks() { + let fixture = Fixture::new(); + let key = key(); + let original = b"{ not json"; + let path = fixture.seed(&key, original); + assert!(matches!(fixture.store.load(&key).await, WorkspaceLoad::Blocked(_))); + + let moved = fixture.store.reset(&key).await.expect("reset").expect("a moved file"); + + let moved_name = moved.file_name().expect("a name").to_string_lossy().into_owned(); + let name = WorkspaceFileName::for_key(&key).file_name(); + assert!(moved_name.starts_with(&format!("{name}.corrupt-")), "{moved_name}"); + assert_eq!(std::fs::read(&moved).expect("read the moved file"), original); + assert!(!path.exists()); + assert!(!fixture.store.is_blocked(&key)); + fixture.store.schedule(&key, record(1)).expect("saves again"); + fixture.store.flush(&key).await.expect("written"); + assert_eq!(fixture.on_disk(&key), record(1)); + } + + #[tokio::test] + async fn reset_without_a_file_unblocks() { + let fixture = Fixture::new(); + + assert_eq!(fixture.store.reset(&key()).await, Ok(None)); + } + + #[tokio::test] + async fn writer_coalesces_latest_record_per_key() { + let root = tempfile::tempdir().expect("tempdir"); + let written: Arc>> = Arc::default(); + let (started, mut starts) = mpsc::unbounded_channel::<()>(); + // Holds the first write open, so the rest arrive while it runs. + let (release, hold) = std::sync::mpsc::channel::<()>(); + let hold = Mutex::new(hold); + let log = written.clone(); + let write: Arc = Arc::new(move |path: &Path, bytes: &[u8]| { + let record: WorkspaceRecord = decode_document(path, bytes).expect("a record"); + let first = { + let mut log = log.lock().expect("the log"); + log.push((path.to_owned(), record.tabs.len())); + log.len() == 1 + }; + let _ = started.send(()); + if first { + let _ = hold.lock().expect("the hold").recv(); + } + Ok(()) + }); + let store = WorkspaceStore::start_with(root.path().to_owned(), &paused_tasks(), write); + let (a, b) = (key(), key()); + let path_a = store.inner.path(&a); + let path_b = store.inner.path(&b); + + store.schedule(&a, record(1)).expect("scheduled"); + starts.recv().await.expect("the first write started"); + for tables in [2, 3] { + store.schedule(&a, record(tables)).expect("scheduled"); + } + store.schedule(&b, record(4)).expect("scheduled"); + release.send(()).expect("release the first write"); + store.flush(&a).await.expect("a written"); + store.flush(&b).await.expect("b written"); + + let written = written.lock().expect("the log").clone(); + let for_path = |path: &PathBuf| -> Vec { + written + .iter() + .filter(|(written, _)| written == path) + .map(|(_, tables)| *tables) + .collect() + }; + assert_eq!(for_path(&path_a), [1, 3], "{written:?}"); + assert_eq!(for_path(&path_b), [4], "{written:?}"); + } + + #[tokio::test] + async fn a_failed_write_is_reported_by_flush() { + let root = tempfile::tempdir().expect("tempdir"); + let write: Arc = Arc::new(|path: &Path, _: &[u8]| { + Err(StorageError::Io { + path: path.to_owned(), + source: std::io::Error::other("disk full"), + }) + }); + let store = WorkspaceStore::start_with(root.path().to_owned(), &paused_tasks(), write); + let key = key(); + + store.schedule(&key, record(1)).expect("scheduled"); + + assert!(matches!(store.flush(&key).await, Err(WorkspaceSaveError::Write(_)))); + } +} diff --git a/linux/crates/app/src/workspace/write_job.rs b/linux/crates/app/src/workspace/write_job.rs new file mode 100644 index 0000000000..59e5e6f086 --- /dev/null +++ b/linux/crates/app/src/workspace/write_job.rs @@ -0,0 +1,19 @@ +use std::path::PathBuf; + +use tokio::sync::oneshot; + +use super::{WorkspaceRecord, WorkspaceSaveError}; + +/// What the writer task is asked to do, in the order it was asked. +pub(super) enum WriteJob { + Write { + path: PathBuf, + record: WorkspaceRecord, + }, + /// Answered once every record queued before it for `path` is on + /// disk, with how the last write of that file went. + Flush { + path: PathBuf, + written: oneshot::Sender>, + }, +} From 76f488302b21028ce8a806305e11700c65f7be76 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 22:49:47 +0700 Subject: [PATCH 105/178] refactor(linux): save each session's tabs off the GTK thread Editor tabs emit content-changed with no text; the tab label reads at most 200 characters. Drafts move to one scope per session, so opening one connection no longer deletes another's editor drafts. --- linux/ARCHITECTURE.md | 5 +- linux/CHANGELOG.md | 4 + linux/crates/app/src/lib.rs | 5 +- linux/crates/app/src/services/mod.rs | 32 - .../app/src/services/workspace_state.rs | 404 ------------ linux/crates/app/src/ui/app/connection.rs | 16 +- linux/crates/app/src/ui/app/mod.rs | 56 +- .../app/src/ui/app/workspace_persistence.rs | 383 ++++++++++++ linux/crates/app/src/ui/app/workspace_tabs.rs | 573 ++++++++++-------- linux/crates/app/src/ui/editor/imp.rs | 6 +- linux/crates/app/src/ui/editor/mod.rs | 10 +- linux/crates/app/src/ui/editor/tests.rs | 13 + linux/crates/storage/src/paths.rs | 4 - linux/docs/storage.md | 2 +- linux/po/POTFILES.in | 11 +- 15 files changed, 779 insertions(+), 745 deletions(-) delete mode 100644 linux/crates/app/src/services/workspace_state.rs create mode 100644 linux/crates/app/src/ui/app/workspace_persistence.rs diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index b3a8a734cf..ded8b99666 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -235,14 +235,15 @@ The Structure tab is **snapshot + diff**, not per-op log. `original_*` snapshots |---|---|---| | Saved connections | JSON, `g_file_set_contents_full` CONSISTENT + DURABLE, 0600 | `$XDG_CONFIG_HOME/tablepro/connections.json` | | Connection passwords + SSH secrets | libsecret via `oo7` (Secret Service / KWallet) | keyring item per connection UUID | -| Per-connection workspace tabs | JSON, durable write, debounced 500 ms | `$XDG_STATE_HOME/tablepro/workspace_state.json` | +| Workspace tabs, one file per session | JSON, durable write, debounced 500 ms, off the GTK thread | `$XDG_STATE_HOME/tablepro/workspaces/.json`, `file-.json` for an unsaved file | +| Editor tab text | one file per tab, debounced 500 ms | `$XDG_DATA_HOME/tablepro/drafts//` | | Query history | SQLite + FTS5 virtual table, 0600 | `$XDG_STATE_HOME/tablepro/history.db` | | Application preferences | GSettings | schema `app.tablepro.TablePro` | | Window size / maximized | GSettings | schema `app.tablepro.TablePro` | | Per-table column widths | JSON, durable write | `$XDG_CONFIG_HOME/tablepro/column_widths.json` | | Single-instance lock | `flock` on an empty file | `$XDG_RUNTIME_DIR/app//tablepro.lock` | -Forward compat: `WorkspaceTabRecord` uses `#[serde(other)] Unknown` so an old binary reading a newer file silently skips unknown variants instead of failing the whole load. `clamp_connection` runs on load to migrate legacy variants (`Browse`, `Structure { schema, table }`) into the unified `Table` shape. +`WorkspaceStore` reads a session's file once its session is adopted, and the window writes the tab list only after that read, so a write never lands on tabs not yet restored. A file that cannot be read, or that another version wrote, is left as it is and nothing is written over it until the user picks Reset on the toast, which moves it aside as `.json.corrupt-`. Writes go through one task that keeps the newest record per file. Editor records carry a draft id, never the text, and the editor's `content-changed` signal carries no text either: the tab label reads at most 200 characters. `SavedConnection::last_opened_at: Option>` is stamped on each successful connect via `touch_last_opened`. The welcome view sorts by recency-first with alphabetical tiebreaker; never-opened entries fall to the bottom. diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 5fd77c9a4e..34a803c51e 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -49,6 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A refresh that failed replaced the rows in a table tab with an error page. - Switching connections could show the previous connection's tables in the sidebar, or its reconnect banner. - Switching connections during a table drop could close the new connection's tabs on a table of the same name. +- Saved tabs were lost on a disconnect or window close right after a tab change, and for every connection when their file could not be read. +- Opening a connection emptied the editor tabs saved for other connections. +- Structure tabs did not reopen with their connection. +- Typing in a long SQL script copied the whole script on every keystroke. ### Security diff --git a/linux/crates/app/src/lib.rs b/linux/crates/app/src/lib.rs index e32f316ef3..19ecb0945d 100644 --- a/linux/crates/app/src/lib.rs +++ b/linux/crates/app/src/lib.rs @@ -65,10 +65,9 @@ fn start() -> Result<(), StartupError> { // Single-instance gate: belt-and-suspenders flock on top of // gtk::Application's DBus-based uniqueness, since the latter // silently lets two processes through when DBus is unavailable. - // A second instance corrupts workspace_state.json via concurrent - // read-modify-write. Hold the lock through the entire `start`. + // A second instance would write the same state files beside this + // one. Hold the lock through the entire `start`. let paths = tablepro_storage::StoragePaths::from_glib(config::storage_dir_name(), config::APP_ID); - services::install_paths(paths.clone()); let _instance_lock = match services::single_instance::acquire(&paths) { Ok(lock) => Some(lock), diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index 2e0b608607..93e14e5abd 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -1,7 +1,3 @@ -use std::sync::OnceLock; - -use tablepro_storage::StoragePaths; - pub mod active_session; pub mod cell_overlay; pub mod change_tracker; @@ -22,31 +18,3 @@ pub mod touch; pub mod tracker_delta; pub mod tracker_edit; pub mod undo_op; -pub mod workspace_state; - -static PATHS: OnceLock = OnceLock::new(); - -/// `run()` installs the resolved paths before any component starts, so -/// the file-backed services below never resolve a directory themselves. -/// FA 3 replaces this with the services object. -pub fn install_paths(paths: StoragePaths) { - let _ = PATHS.set(paths); -} - -pub(crate) fn paths() -> Option<&'static StoragePaths> { - PATHS.get() -} - -/// Serialise and write durably at 0600, reporting where it failed. -pub(crate) fn write_json(path: &std::path::Path, value: &T, what: &str) { - let bytes = match serde_json::to_vec_pretty(value) { - Ok(bytes) => bytes, - Err(error) => { - tracing::warn!(%error, what, "could not serialise state"); - return; - } - }; - if let Err(error) = tablepro_storage::fs::write_private_blocking(path, &bytes) { - tracing::warn!(%error, what, "could not save state"); - } -} diff --git a/linux/crates/app/src/services/workspace_state.rs b/linux/crates/app/src/services/workspace_state.rs deleted file mode 100644 index ec652b8588..0000000000 --- a/linux/crates/app/src/services/workspace_state.rs +++ /dev/null @@ -1,404 +0,0 @@ -use std::collections::HashMap; -use std::sync::Mutex; - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// Serialises every read-modify-write of `workspace_state.json`. Each -/// `save_connection` call loads the current state, mutates one entry, -/// and rewrites the whole file; without this lock, two close events -/// firing in quick succession can race with overlapping load → save -/// pairs and silently drop one of the writes. The lock is held for -/// the duration of the load + serialise + atomic-rename sequence — -/// short enough that contention is negligible, long enough to make -/// the sequence atomic from any other thread's perspective. -static FILE_LOCK: Mutex<()> = Mutex::new(()); - -const MAX_TABS_PER_CONNECTION: usize = 32; -const MAX_TABLE_NAME_BYTES: usize = 256; -const MAX_SCHEMA_NAME_BYTES: usize = 256; - -const PAGE_SIZE_OPTIONS: &[u64] = &[100, 500, 1_000, 5_000, 10_000]; -const DEFAULT_PAGE_SIZE: u64 = 1_000; - -/// Unified workspace persistence: one tab strip per connection containing -/// both Browse and Editor tabs in user-chosen display order. Replaces the -/// previous split between browse_state.json and editor.json. -/// -/// Per-connection_id because tabs are written against a specific schema — -/// pulling them across connections silently switches their semantic -/// meaning. Selection state is intentionally omitted (ephemeral). -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct WorkspaceState { - #[serde(default)] - pub connections: HashMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ConnectionWorkspaceState { - pub tabs: Vec, - #[serde(default)] - pub active_idx: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum WorkspaceTabRecord { - Browse { - schema: Option, - table: String, - #[serde(default)] - offset: u64, - #[serde(default = "default_page_size")] - page_size: u64, - #[serde(default)] - sort_col: Option, - #[serde(default)] - sort_asc: Option, - }, - Editor { - /// The file the tab's text lives in. Keeping it out of this - /// record is what lets a script be any length. - #[serde(default)] - draft: tablepro_storage::DraftId, - /// What a build before drafts wrote inline, cut at 256 KiB. - /// Read once so an upgrade does not lose an open script, and - /// never written again. - #[serde(default, skip_serializing)] - query: String, - }, - /// Persisted Structure tab (Edit mode only — `New` mode tabs are - /// drafts for tables that don't exist yet, so they don't survive - /// a disconnect). - Structure { schema: Option, table: String }, - /// Persisted Table tab (canonical M-1 form). Carries the - /// user-visible mode (data vs structure) so the tab restores to - /// the same lens. `offset` / `sort_col` / `sort_asc` describe the - /// Browse side's last view; the Structure side rehydrates from - /// driver introspection on first switch. - Table { - schema: Option, - table: String, - #[serde(default)] - mode: PersistedTableMode, - #[serde(default)] - offset: u64, - #[serde(default = "default_page_size")] - page_size: u64, - #[serde(default)] - sort_col: Option, - #[serde(default)] - sort_asc: Option, - }, - /// Forward-compat: an older binary reading a workspace_state.json - /// written by a newer binary lands tabs of unrecognised kinds in - /// this variant. `clamp_connection` and `restore_workspace_tabs` - /// drop them silently rather than failing the entire load. - #[serde(other)] - Unknown, -} - -fn default_page_size() -> u64 { - DEFAULT_PAGE_SIZE -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum PersistedTableMode { - #[default] - Data, - Structure, -} - -fn load_locked() -> WorkspaceState { - let Some(path) = super::paths().map(|paths| paths.workspace_state_file()) else { - return WorkspaceState::default(); - }; - let mut state: WorkspaceState = std::fs::read(path) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()) - .unwrap_or_default(); - clamp(&mut state); - state -} - -fn save_locked(state: &WorkspaceState) { - let Some(path) = super::paths().map(|paths| paths.workspace_state_file()) else { - tracing::warn!("workspace_state: no config path; skipping save"); - return; - }; - let mut snapshot = state.clone(); - clamp(&mut snapshot); - super::write_json(&path, &snapshot, "workspace state"); -} - -pub fn load_connection(id: Uuid) -> Option { - let _guard = FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let state = load_locked(); - state.connections.get(&id.to_string()).cloned() -} - -pub fn save_connection(id: Uuid, conn_state: ConnectionWorkspaceState) { - // Hold the lock across the load + insert + save so a concurrent - // save_connection on a different connection doesn't read a stale - // copy and overwrite our entry's neighbours. - let _guard = FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let mut state = load_locked(); - state.connections.insert(id.to_string(), conn_state); - save_locked(&state); -} - -fn clamp(state: &mut WorkspaceState) { - for conn in state.connections.values_mut() { - clamp_connection(conn); - } -} - -fn clamp_connection(conn: &mut ConnectionWorkspaceState) { - // Drop forward-compat Unknown variants up front so they never - // contribute to the tab count or the active_idx selection. - conn.tabs.retain(|t| !matches!(t, WorkspaceTabRecord::Unknown)); - if conn.tabs.len() > MAX_TABS_PER_CONNECTION { - conn.tabs.truncate(MAX_TABS_PER_CONNECTION); - } - // Migrate legacy Browse / Structure records to Table so the rest - // of the load path (and clamp logic) only deals with one variant. - // Browse → Table(Data); Structure → Table(Structure). - for tab in &mut conn.tabs { - let migrated = match std::mem::replace(tab, WorkspaceTabRecord::Unknown) { - WorkspaceTabRecord::Browse { - schema, - table, - offset, - page_size, - sort_col, - sort_asc, - } => WorkspaceTabRecord::Table { - schema, - table, - mode: PersistedTableMode::Data, - offset, - page_size, - sort_col, - sort_asc, - }, - WorkspaceTabRecord::Structure { schema, table } => WorkspaceTabRecord::Table { - schema, - table, - mode: PersistedTableMode::Structure, - offset: 0, - page_size: DEFAULT_PAGE_SIZE, - sort_col: None, - sort_asc: None, - }, - other => other, - }; - *tab = migrated; - } - for tab in &mut conn.tabs { - match tab { - // An editor record is an id, so there is nothing to clamp. - WorkspaceTabRecord::Editor { .. } => {} - WorkspaceTabRecord::Table { - schema, - table, - page_size, - .. - } => { - if table.len() > MAX_TABLE_NAME_BYTES { - let boundary = floor_char_boundary(table, MAX_TABLE_NAME_BYTES); - table.truncate(boundary); - } - if let Some(s) = schema.as_mut() - && s.len() > MAX_SCHEMA_NAME_BYTES - { - let boundary = floor_char_boundary(s, MAX_SCHEMA_NAME_BYTES); - s.truncate(boundary); - } - if !PAGE_SIZE_OPTIONS.contains(page_size) { - *page_size = DEFAULT_PAGE_SIZE; - } - } - WorkspaceTabRecord::Browse { .. } | WorkspaceTabRecord::Structure { .. } => { - // Unreachable: legacy variants were converted above. - } - WorkspaceTabRecord::Unknown => { - // Unreachable: stripped by the retain() above. - } - } - } - if (conn.active_idx as usize) >= conn.tabs.len() { - conn.active_idx = 0; - } -} - -fn floor_char_boundary(s: &str, idx: usize) -> usize { - if idx >= s.len() { - return s.len(); - } - let mut b = idx; - while b > 0 && !s.is_char_boundary(b) { - b -= 1; - } - b -} - -#[cfg(test)] -mod tests { - use super::*; - - fn browse(table: &str) -> WorkspaceTabRecord { - WorkspaceTabRecord::Browse { - schema: None, - table: table.into(), - offset: 0, - page_size: DEFAULT_PAGE_SIZE, - sort_col: None, - sort_asc: None, - } - } - - fn editor() -> WorkspaceTabRecord { - WorkspaceTabRecord::Editor { - draft: tablepro_storage::DraftId::new(), - query: String::new(), - } - } - - #[test] - fn clamp_truncates_tabs_beyond_limit() { - let mut conn = ConnectionWorkspaceState { - tabs: (0..40).map(|i| browse(&format!("t{i}"))).collect(), - active_idx: 35, - }; - clamp_connection(&mut conn); - assert_eq!(conn.tabs.len(), MAX_TABS_PER_CONNECTION); - assert_eq!(conn.active_idx, 0); - } - - #[test] - fn clamp_handles_mixed_browse_and_editor_tabs() { - let mut conn = ConnectionWorkspaceState { - tabs: vec![browse("users"), editor(), browse("orders")], - active_idx: 1, - }; - clamp_connection(&mut conn); - assert_eq!(conn.tabs.len(), 3); - assert_eq!(conn.active_idx, 1); - assert!(matches!(conn.tabs[1], WorkspaceTabRecord::Editor { .. })); - } - - #[test] - fn clamp_replaces_foreign_browse_page_size() { - let mut conn = ConnectionWorkspaceState { - tabs: vec![WorkspaceTabRecord::Browse { - schema: None, - table: "t".into(), - offset: 0, - page_size: 999_999, - sort_col: None, - sort_asc: None, - }], - active_idx: 0, - }; - clamp_connection(&mut conn); - // Legacy Browse migrates to Table(Data) and the foreign page - // size is replaced with the default in the same pass. - match &conn.tabs[0] { - WorkspaceTabRecord::Table { mode, page_size, .. } => { - assert_eq!(*mode, PersistedTableMode::Data); - assert_eq!(*page_size, DEFAULT_PAGE_SIZE); - } - _ => panic!("expected Table after migration"), - } - } - - #[test] - fn an_editor_record_carries_a_draft_id_not_the_text() { - // The text used to live here and was cut at 256 KiB, which - // silently halved a long migration script. - let record = editor(); - let json = serde_json::to_string(&record).expect("serialise"); - - let parsed: WorkspaceTabRecord = serde_json::from_str(&json).expect("parse"); - - match (&record, &parsed) { - (WorkspaceTabRecord::Editor { draft: wrote, .. }, WorkspaceTabRecord::Editor { draft: read, .. }) => { - assert_eq!(wrote, read) - } - other => panic!("expected two Editor records, got {other:?}"), - } - assert!(json.len() < 100, "the record carries more than an id: {json}"); - assert!( - !json.contains("query"), - "the text was written back into the record: {json}" - ); - } - - #[test] - fn an_editor_record_from_before_drafts_keeps_its_text_for_one_read() { - let json = r#"{"kind":"editor","query":"SELECT 1"}"#; - - let parsed: WorkspaceTabRecord = serde_json::from_str(json).expect("parse a legacy record"); - - match &parsed { - WorkspaceTabRecord::Editor { query, .. } => assert_eq!(query, "SELECT 1"), - other => panic!("expected Editor, got {other:?}"), - } - } - - #[test] - fn round_trip_preserves_mixed_tabs() { - let mut state = WorkspaceState::default(); - let id = Uuid::new_v4(); - state.connections.insert( - id.to_string(), - ConnectionWorkspaceState { - tabs: vec![ - browse("users"), - editor(), - WorkspaceTabRecord::Browse { - schema: Some("public".into()), - table: "products".into(), - offset: 5000, - page_size: 5_000, - sort_col: Some(2), - sort_asc: Some(false), - }, - ], - active_idx: 1, - }, - ); - let bytes = serde_json::to_vec(&state).unwrap(); - let parsed: WorkspaceState = serde_json::from_slice(&bytes).unwrap(); - let conn = parsed.connections.get(&id.to_string()).unwrap(); - assert_eq!(conn.tabs.len(), 3); - assert_eq!(conn.active_idx, 1); - match &conn.tabs[2] { - WorkspaceTabRecord::Browse { sort_col, sort_asc, .. } => { - assert_eq!(*sort_col, Some(2)); - assert_eq!(*sort_asc, Some(false)); - } - _ => panic!("expected Browse"), - } - } - - #[test] - fn legacy_record_loads_with_serde_defaults() { - // Forward-compat: missing optional fields fall back to defaults. - let json = r#"{"connections":{"abc":{"tabs":[{"kind":"browse","schema":null,"table":"t"},{"kind":"editor"}],"active_idx":0}}}"#; - let parsed: WorkspaceState = serde_json::from_str(json).unwrap(); - let tabs = &parsed.connections["abc"].tabs; - match &tabs[0] { - WorkspaceTabRecord::Browse { offset, page_size, .. } => { - assert_eq!(*offset, 0); - assert_eq!(*page_size, DEFAULT_PAGE_SIZE); - } - _ => panic!("expected Browse"), - } - match &tabs[1] { - WorkspaceTabRecord::Editor { query, .. } => assert_eq!(query, ""), - _ => panic!("expected Editor"), - } - } -} diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index de93fce359..b868902323 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -392,10 +392,10 @@ impl App { tracing::info!(driver = %driver_id, table_count = objects.len(), "workspace ready"); self.show_objects(&objects); self.refresh_window_title(); - // Restore tabs (browse + editor) persisted from the prior session - // for this connection. + if let Some(key) = self.session_key().cloned() { + self.restore_workspace_tabs(key, sender.clone()); + } if let Some(connection_id) = self.context.active.metadata().map(|metadata| metadata.id) { - self.restore_workspace_tabs(connection_id, sender.clone()); // Stamp `last_opened_at = now()` then reload connections so // the popover + welcome view re-sort with the fresh // timestamp. Sequencing matters: ReloadConnections reads @@ -419,6 +419,12 @@ impl App { sender.input(AppMsg::ReloadConnections); } + /// The key this window's session is claimed under, which its saved + /// tabs are kept by. + pub(super) fn session_key(&self) -> Option<&SessionKey> { + self.session_links.as_ref().map(|links| &links.key) + } + pub(super) fn on_disconnect(&mut self, sender: ComponentSender) { self.confirm_teardown(Teardown::Disconnect, AppMsg::ForceDisconnect, sender); } @@ -436,8 +442,8 @@ impl App { /// held until the close ends, so quitting right after a disconnect /// does not cut the connection off mid-close. fn release_session(&mut self, _sender: ComponentSender) { - // Persist + tear down workspace tabs before dropping the - // session (persist needs the active connection id). + // The tabs are saved under the session's key, which goes with + // the session links below. self.teardown_workspace_tabs(); // The closed tabs name tables of the connection being released. self.clear_closed_tabs_stack(); diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 39034f3e66..5bfc7589d5 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -6,6 +6,7 @@ mod row_ops; mod status_pages; mod structure; mod teardown_guard; +mod workspace_persistence; mod workspace_tabs; use relm4::adw::prelude::*; @@ -163,11 +164,11 @@ pub struct App { /// dispatch path short-circuit. Cleared on /// `StructureSaveCompleted` / `StructureSaveFailed`. structure_saves_in_flight: std::rc::Rc>>, - /// Coalesces `persist_workspace_state`. Tabs fire + /// Writes the tab list for the session this window shows. Tabs fire /// `WorkspaceTabsChanged` on every selection, drag-reorder, - /// page-size change and state-changed event, and each one would - /// otherwise rewrite the whole workspace file. - persist_debouncer: crate::workspace::PersistDebouncer, + /// page-size change and state-changed event, and it writes once + /// after a burst. + workspace: std::rc::Rc, /// Recently closed tabs for Ctrl+Shift+T. Cleared on disconnect, /// since the tables they name belong to that connection. Pending /// edits are not kept: the close has already cleared them. @@ -175,8 +176,8 @@ pub struct App { } /// Snapshot of a tab the user just closed, retained for Ctrl+Shift+T -/// reopen. Mirrors the persistence variants in `workspace_state` so -/// reopen routes back through the same `append_*` constructors. +/// reopen, so reopen routes back through the same `append_*` +/// constructors as a restore. #[derive(Debug, Clone)] pub enum ClosedTabDescriptor { Editor { @@ -197,17 +198,12 @@ pub enum ClosedTabDescriptor { pub(super) const CLOSED_TABS_CAPACITY: usize = 10; -/// How long after the last tab change the workspace file is rewritten. -const WORKSPACE_PERSIST_DELAY: std::time::Duration = std::time::Duration::from_millis(500); - pub struct EditorTabSlot { pub editor: SqlEditor, pub page: adw::TabPage, /// The file this tab's text is written to. The text itself lives in /// the buffer, not here. pub draft: tablepro_storage::DraftId, - /// The first line or so, for the tab label and the reopen stack. - pub query: String, } pub struct StructureTabSlot { @@ -351,12 +347,13 @@ pub enum AppMsg { /// Ctrl+W → close active workspace tab (browse or editor). CloseActiveWorkspaceTab, EditorTabRunStateChanged(Uuid, bool), - EditorTabQueryChanged(Uuid, String), - /// The saved tabs and their editor text, read off the GTK thread. - WorkspaceTabsRead { - saved: crate::services::workspace_state::ConnectionWorkspaceState, - drafts: std::collections::HashMap, - }, + EditorTabContentChanged(Uuid), + /// The session's saved tabs and their editor text, read off the GTK + /// thread. + WorkspaceLoaded(FromSession), + /// The toast's Reset: move the unreadable saved tabs aside. + ResetWorkspaceLayout, + WorkspaceLayoutReset(FromSession, crate::workspace::WorkspaceSaveError>>), ShowHistory, ShowSavedQueries, /// Keep the active editor tab's SQL under a name the user picks. @@ -743,6 +740,7 @@ impl SimpleComponent for App { tablepro_storage::DraftStore::new(storage.paths().drafts_dir()), tasks.clone(), ); + let workspace = workspace_persistence::WorkspacePersistence::new(storage.workspaces().clone()); let widgets = view_output!(); if crate::config::profile() == crate::config::Profile::Development { @@ -758,6 +756,7 @@ impl SimpleComponent for App { let settings_for_close = settings.clone(); let storage_for_close = storage.clone(); let drafts_for_close = drafts.clone(); + let workspace_for_close = workspace.clone(); let force_close: std::rc::Rc> = std::rc::Rc::new(std::cell::Cell::new(false)); let force_close_for_close = force_close.clone(); let close_after_save_for_close: std::rc::Rc>> = @@ -894,12 +893,18 @@ impl SimpleComponent for App { let column_widths = storage_for_close.column_widths().flush(); let filter_settings = storage_for_close.filter_settings().flush(); let editor_drafts = drafts_for_close.flush_all(); + let tabs = workspace_for_close.detach(); glib::spawn_future_local(async move { column_widths.await; filter_settings.await; for failure in editor_drafts.await { tracing::warn!(failure, "an editor draft was not saved"); } + if let Some(tabs) = tabs + && let Err(error) = tabs.await + { + tracing::debug!(%error, "the open tabs were not saved"); + } drop(hold); }); glib::Propagation::Proceed @@ -1230,7 +1235,7 @@ impl SimpleComponent for App { close_window_after_save: close_window_after_save_handle, in_flight_saves: in_flight_saves_handle, structure_saves_in_flight: std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashSet::new())), - persist_debouncer: crate::workspace::PersistDebouncer::new(WORKSPACE_PERSIST_DELAY), + workspace, closed_tabs_stack: std::rc::Rc::new(std::cell::RefCell::new(std::collections::VecDeque::with_capacity( CLOSED_TABS_CAPACITY, ))), @@ -1419,8 +1424,18 @@ impl SimpleComponent for App { } AppMsg::NewEditorTab => self.append_editor_tab(None, sender), AppMsg::EditorTabRunStateChanged(id, running) => self.on_editor_tab_run_state_changed(id, running), - AppMsg::EditorTabQueryChanged(id, text) => self.on_editor_tab_query_changed(id, text), - AppMsg::WorkspaceTabsRead { saved, drafts } => self.on_workspace_tabs_read(saved, drafts, sender), + AppMsg::EditorTabContentChanged(id) => self.on_editor_tab_content_changed(id), + AppMsg::WorkspaceLoaded(update) => { + if let Some(loaded) = update.if_current(&self.context.active) { + self.on_workspace_loaded(loaded, sender); + } + } + AppMsg::ResetWorkspaceLayout => self.on_reset_workspace_layout(sender), + AppMsg::WorkspaceLayoutReset(update) => { + if let Some(outcome) = update.if_current(&self.context.active) { + self.on_workspace_layout_reset(outcome); + } + } AppMsg::ShowHistory => self.on_show_history(sender), AppMsg::ShowSavedQueries => self.on_show_saved_queries(sender), AppMsg::SetConnectionColor(id, color) => self.on_set_connection_color(id, color, sender), @@ -1594,6 +1609,7 @@ fn install_window_actions(window: &adw::ApplicationWindow, sender: ComponentSend input_action!("save-query", AppMsg::SaveActiveQuery), input_action!("export-connections", AppMsg::ExportConnections), input_action!("import-connections", AppMsg::ImportConnections), + input_action!("reset-workspace-layout", AppMsg::ResetWorkspaceLayout), ]); let disconnect = gio::SimpleAction::new("disconnect", None); let sender_for_disconnect = sender.clone(); diff --git a/linux/crates/app/src/ui/app/workspace_persistence.rs b/linux/crates/app/src/ui/app/workspace_persistence.rs new file mode 100644 index 0000000000..93ed9f0489 --- /dev/null +++ b/linux/crates/app/src/ui/app/workspace_persistence.rs @@ -0,0 +1,383 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::time::Duration; + +use relm4::adw; +use relm4::adw::prelude::*; +use relm4::gtk::glib; +use tablepro_session::SessionKey; +use uuid::Uuid; + +use super::{WorkspaceTab, read_workspace_tab_id}; +use crate::ui::structure_tab::StructureMode; +use crate::workspace::{PersistDebouncer, SortRecord, TabRecord, WorkspaceRecord, WorkspaceSaveError, WorkspaceStore}; + +/// How long after the last tab change the tab list is written. +const PERSIST_DELAY: Duration = Duration::from_millis(500); + +pub(super) type WorkspaceTabs = Rc>>; + +/// Keeps the tab list of the session a window shows on disk, behind the +/// user. +pub(super) struct WorkspacePersistence { + store: WorkspaceStore, + debouncer: PersistDebouncer, + target: RefCell>, +} + +/// The session the tabs belong to. It is set only once that session's +/// saved tabs have been read, so nothing is written over them first. +struct Target { + key: SessionKey, + view: glib::WeakRef, + tabs: WorkspaceTabs, +} + +impl Target { + fn record(&self) -> Option { + let view = self.view.upgrade()?; + Some(workspace_record(&view, &self.tabs.borrow())) + } +} + +impl WorkspacePersistence { + pub fn new(store: WorkspaceStore) -> Rc { + Rc::new(Self { + store, + debouncer: PersistDebouncer::new(PERSIST_DELAY), + target: RefCell::new(None), + }) + } + + /// Start writing the tabs of `key`, whose saved tabs have been read. + pub fn attach(&self, key: SessionKey, view: &adw::TabView, tabs: WorkspaceTabs) { + self.debouncer.cancel(); + *self.target.borrow_mut() = Some(Target { + key, + view: view.downgrade(), + tabs, + }); + } + + /// Write the tab list a moment after the last change. + pub fn request(self: &Rc) { + // Taken now: the write belongs to the session open now, even if + // it fires after a switch to another one. + let Some(key) = self.target.borrow().as_ref().map(|target| target.key.clone()) else { + return; + }; + let this = Rc::downgrade(self); + self.debouncer.request(move || { + if let Some(this) = this.upgrade() { + this.write(&key); + } + }); + } + + /// Write the tabs as they are now and stop writing for this session. + /// The future resolves once the record is on disk. + pub fn detach(&self) -> Option> + use<>> { + // A request still pending would read the tabs after the teardown + // emptied them, and save an empty list. + self.debouncer.cancel(); + let target = self.target.borrow_mut().take()?; + if let Some(record) = target.record() { + self.schedule(&target.key, record); + } + Some(self.store.flush(&target.key)) + } + + fn write(&self, key: &SessionKey) { + let record = match self.target.borrow().as_ref() { + Some(target) if &target.key == key => target.record(), + _ => None, + }; + if let Some(record) = record { + self.schedule(key, record); + } + } + + fn schedule(&self, key: &SessionKey, record: WorkspaceRecord) { + match self.store.schedule(key, record) { + Ok(()) => {} + Err(WorkspaceSaveError::Blocked) => { + tracing::debug!("the saved tabs could not be read, so the open ones are not written over them"); + } + Err(error) => tracing::warn!(%error, "could not save the open tabs"), + } + } +} + +pub(super) fn workspace_record(view: &adw::TabView, tabs: &HashMap) -> WorkspaceRecord { + let selected = view.selected_page(); + let pages = view.pages(); + let mut record = WorkspaceRecord::default(); + for index in 0..pages.n_items() { + let Some(page) = pages.item(index).and_downcast::() else { + continue; + }; + let Some(tab) = read_workspace_tab_id(&page).and_then(|id| tabs.get(&id)) else { + continue; + }; + let Some(saved) = tab_record(tab) else { + continue; + }; + if selected.as_ref() == Some(&page) { + record.active_tab = u32::try_from(record.tabs.len()).ok(); + } + record.tabs.push(saved); + } + record +} + +fn tab_record(tab: &WorkspaceTab) -> Option { + match tab { + WorkspaceTab::Editor(slot) => Some(TabRecord::Editor { draft: slot.draft }), + WorkspaceTab::Structure(slot) => match slot.mode { + // A draft for a table that does not exist yet has nothing to + // reopen. + StructureMode::New => None, + StructureMode::Edit => Some(TabRecord::Structure { + schema: slot.schema.clone(), + table: slot.table.clone(), + }), + }, + WorkspaceTab::Table(slot) => Some(TabRecord::Table { + schema: slot.schema.clone(), + table: slot.table.clone(), + offset: slot.browse.current_offset(), + page_size: slot.browse.page_size(), + sort: slot + .browse + .current_sort() + .map(|(column, ascending)| SortRecord { column, ascending }), + }), + } +} + +/// Open each saved tab through `open`, in order, then select the one +/// that was selected. A tab `open` could not build leaves nothing to +/// select. +pub(super) fn reopen( + view: &adw::TabView, + record: WorkspaceRecord, + mut open: impl FnMut(TabRecord) -> Option, +) { + let pages: Vec> = record.tabs.into_iter().map(&mut open).collect(); + let selected = record + .active_tab + .and_then(|index| usize::try_from(index).ok()) + .and_then(|index| pages.get(index).cloned().flatten()); + if let Some(page) = selected { + view.set_selected_page(&page); + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use relm4::gtk; + use tablepro_storage::DraftId; + + use super::*; + use crate::test_support::{finish_on_main, wait_until}; + use crate::ui::app::{EditorTabSlot, write_workspace_tab_id}; + use crate::ui::editor::{SqlEditor, SqlEditorInit}; + use crate::workspace::WorkspaceLoad; + + const WAIT: Duration = Duration::from_secs(5); + + struct Fixture { + runtime: crate::runtime::AppRuntime, + settings: crate::test_support::MemorySettings, + root: tempfile::TempDir, + store: WorkspaceStore, + view: adw::TabView, + tabs: WorkspaceTabs, + } + + impl Fixture { + fn new() -> Self { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let store = WorkspaceStore::start(root.path().join("workspaces"), &runtime.tasks()); + Self { + runtime, + settings: crate::test_support::MemorySettings::new(), + root, + store, + view: adw::TabView::new(), + tabs: WorkspaceTabs::default(), + } + } + + fn add_editor(&self) -> (adw::TabPage, DraftId) { + let editor = SqlEditor::new(SqlEditorInit { + schema_buffer: crate::ui::editor::build_schema_buffer(), + initial_query: None, + settings: self.settings.get().clone(), + history: None, + tasks: self.runtime.tasks(), + session: crate::services::active_session::ActiveSession::new(), + hold: Rc::new(tablepro_session::runtime::CountingHold::new()), + close_timeout: Duration::from_secs(5), + }); + let page = self.view.append(&editor); + let id = Uuid::new_v4(); + write_workspace_tab_id(&page, id); + let draft = DraftId::new(); + self.tabs.borrow_mut().insert( + id, + WorkspaceTab::Editor(EditorTabSlot { + editor, + page: page.clone(), + draft, + }), + ); + (page, draft) + } + + fn load(&self, key: &SessionKey) -> WorkspaceLoad { + finish_on_main(WAIT, self.store.load(key)).expect("the load to end") + } + } + + /// Run the main loop for `duration`, long enough for a timer that + /// must not fire to have fired. + fn run_for(duration: Duration) { + let _ = wait_until(duration, || false); + } + + fn editor_drafts(record: &WorkspaceRecord) -> Vec { + record + .tabs + .iter() + .filter_map(|tab| match tab { + TabRecord::Editor { draft } => Some(*draft), + _ => None, + }) + .collect() + } + + #[gtk4::test] + fn active_tab_indexes_saved_records_not_pages() { + let fixture = Fixture::new(); + // A page with no tab behind it is not saved, so it shifts the + // page index but not the record index. + fixture.view.append(>k::Label::new(None)); + let (_, first) = fixture.add_editor(); + let (selected, second) = fixture.add_editor(); + fixture.view.set_selected_page(&selected); + + let record = workspace_record(&fixture.view, &fixture.tabs.borrow()); + + assert_eq!(editor_drafts(&record), [first, second]); + assert_eq!(record.active_tab, Some(1)); + } + + #[gtk4::test] + fn restore_selects_active_tab_and_reopens_structure_tabs() { + adw::init().expect("libadwaita initialises under the test backend"); + let view = adw::TabView::new(); + let record = WorkspaceRecord { + active_tab: Some(1), + tabs: vec![ + TabRecord::Table { + schema: None, + table: "orders".to_owned(), + offset: 0, + page_size: 1_000, + sort: None, + }, + TabRecord::Structure { + schema: Some("public".to_owned()), + table: "users".to_owned(), + }, + TabRecord::Editor { draft: DraftId::new() }, + ], + }; + let mut opened = Vec::new(); + + reopen(&view, record, |tab| { + let name = match &tab { + TabRecord::Table { table, .. } => format!("table {table}"), + TabRecord::Structure { table, .. } => format!("structure {table}"), + TabRecord::Editor { .. } => "editor".to_owned(), + }; + let page = view.append(>k::Label::new(Some(&name))); + opened.push(name); + Some(page) + }); + + assert_eq!(opened, ["table orders", "structure users", "editor"]); + let selected = view + .selected_page() + .and_then(|page| page.child().downcast::().ok()) + .map(|label| label.label().to_string()); + assert_eq!(selected.as_deref(), Some("structure users")); + } + + #[gtk4::test] + fn detach_writes_the_tabs_as_they_are_now() { + let fixture = Fixture::new(); + let persistence = WorkspacePersistence::new(fixture.store.clone()); + let key = SessionKey::Saved(Uuid::new_v4()); + let (_, draft) = fixture.add_editor(); + persistence.attach(key.clone(), &fixture.view, fixture.tabs.clone()); + + let flushing = persistence.detach().expect("an attached session"); + finish_on_main(WAIT, flushing) + .expect("the flush to end") + .expect("written"); + + let WorkspaceLoad::Restored(record) = fixture.load(&key) else { + panic!("nothing was saved"); + }; + assert_eq!(editor_drafts(&record), [draft]); + assert!(persistence.detach().is_none(), "still attached after a detach"); + } + + #[gtk4::test] + fn a_request_pending_at_teardown_does_not_save_the_emptied_tabs() { + let fixture = Fixture::new(); + let persistence = WorkspacePersistence::new(fixture.store.clone()); + let key = SessionKey::Saved(Uuid::new_v4()); + let (_, draft) = fixture.add_editor(); + persistence.attach(key.clone(), &fixture.view, fixture.tabs.clone()); + + persistence.request(); + let flushing = persistence.detach().expect("an attached session"); + // What the window's teardown does next. + fixture.tabs.borrow_mut().clear(); + finish_on_main(WAIT, flushing) + .expect("the flush to end") + .expect("written"); + run_for(PERSIST_DELAY * 3); + + let WorkspaceLoad::Restored(record) = fixture.load(&key) else { + panic!("nothing was saved"); + }; + assert_eq!(editor_drafts(&record), [draft]); + } + + #[gtk4::test] + fn nothing_is_written_before_the_saved_tabs_are_read() { + let fixture = Fixture::new(); + let persistence = WorkspacePersistence::new(fixture.store.clone()); + fixture.add_editor(); + + persistence.request(); + + run_for(PERSIST_DELAY * 3); + + assert!(persistence.detach().is_none()); + assert!( + !fixture.root.path().join("workspaces").exists(), + "a tab list was written for no session" + ); + } +} diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index 7f703c5e36..adb5c99415 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -1,18 +1,23 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::rc::Rc; +use std::sync::Arc; use relm4::adw::prelude::*; use relm4::gtk::{gio, glib}; use relm4::{ComponentSender, adw, gtk}; +use tablepro_session::SessionKey; +use tablepro_storage::{DraftId, DraftScope}; use uuid::Uuid; -use crate::services::workspace_state::{self, ConnectionWorkspaceState, WorkspaceTabRecord}; +use crate::services::from_session::FromSession; use crate::ui::browse_tab::{BrowseTab, BrowseTabInit, BrowseTabInput, ExportRequest, SaveRequest}; use crate::ui::editor::{SqlEditor, SqlEditorInit, derive_tab_label}; use crate::ui::structure_tab::{StructureMode, StructureTab, StructureTabInit}; +use crate::workspace::{TabRecord, WorkspaceFileName, WorkspaceLoad, WorkspaceRecord, WorkspaceSaveError}; +use super::workspace_persistence::reopen; use super::{ App, AppMsg, CLOSED_TABS_CAPACITY, ClosedTabDescriptor, EditorTabSlot, OpenMode, WorkspaceTab, read_workspace_tab_id, write_workspace_tab_id, @@ -65,107 +70,149 @@ impl App { self.workspace_tab_view = Some(chrome.view); } - /// Read the saved tabs for the just-connected database, plus the - /// editor text that goes with them. - /// - /// A restored script can be megabytes, so it is read on a blocking - /// thread and the tabs are built once it arrives. - pub(super) fn restore_workspace_tabs(&mut self, connection_id: Uuid, sender: ComponentSender) { - let Some(saved) = workspace_state::load_connection(connection_id) else { - self.workspace_outer_stack.set_visible_child_name("empty"); + /// Read the saved tabs of the session just adopted, and the editor + /// text that goes with them, off the GTK thread. + pub(super) fn restore_workspace_tabs(&mut self, key: SessionKey, sender: ComponentSender) { + let Some(session) = self.context.active.get() else { return; }; - if saved.tabs.is_empty() { - self.workspace_outer_stack.set_visible_child_name("empty"); - return; - } - let referenced: HashSet = saved - .tabs - .iter() - .filter_map(|record| match record { - WorkspaceTabRecord::Editor { draft, .. } => Some(*draft), - _ => None, - }) - .collect(); - // Drafts from tabs the user closed in a session that did not - // shut down cleanly have nothing pointing at them. - self.drafts - .retain(&tablepro_storage::DraftScope::default(), referenced.clone()); - + let origin = Arc::downgrade(&session); + let loading = self.storage.workspaces().load(&key); let store = self.drafts.store().clone(); - let reading = self.tasks.spawn_blocking_task(move || { - let scope = tablepro_storage::DraftScope::default(); - referenced - .into_iter() - .filter_map(|draft| match store.read_blocking(&scope, draft) { - Ok(text) => text.map(|text| (draft, text)), - Err(error) => { - tracing::warn!(%error, "could not read an editor draft"); - None - } - }) - .collect::>() - }); + let scope = WorkspaceFileName::for_key(&key).draft_scope(); + let tasks = self.tasks.clone(); glib::spawn_future_local(async move { + let load = loading.await; + let referenced: Vec = match &load { + WorkspaceLoad::Restored(record) => saved_drafts(record).collect(), + WorkspaceLoad::Empty | WorkspaceLoad::Blocked(_) => Vec::new(), + }; + let reading = tasks.spawn_blocking_task(move || { + referenced + .into_iter() + .filter_map(|draft| match store.read_blocking(&scope, draft) { + Ok(text) => text.map(|text| (draft, text)), + Err(error) => { + tracing::warn!(%error, "could not read an editor draft"); + None + } + }) + .collect::>() + }); let drafts = reading.await.unwrap_or_default(); - sender.input(AppMsg::WorkspaceTabsRead { saved, drafts }); + sender.input(AppMsg::WorkspaceLoaded(FromSession::new( + origin, + LoadedWorkspace { key, load, drafts }, + ))); }); } - /// Build the tabs once their text has been read. - pub(super) fn on_workspace_tabs_read( + /// Rebuild the saved tabs, then start writing the tab list. + /// + /// Nothing is written for the session before this: a write would + /// replace the saved tabs before they were read. + pub(super) fn on_workspace_loaded(&mut self, loaded: LoadedWorkspace, sender: ComponentSender) { + let LoadedWorkspace { key, load, mut drafts } = loaded; + let scope = WorkspaceFileName::for_key(&key).draft_scope(); + match load { + WorkspaceLoad::Restored(record) => { + self.retain_drafts(&scope, saved_drafts(&record)); + if let Some(view) = self.workspace_tab_view.clone() { + reopen(&view, record, |tab| self.reopen_tab(tab, &mut drafts, sender.clone())); + } + } + WorkspaceLoad::Empty => self.retain_drafts(&scope, std::iter::empty()), + // Which drafts the file names is unknown, so none is deleted. + WorkspaceLoad::Blocked(_) => self.show_unreadable_workspace_toast(), + } + if let Some(view) = self.workspace_tab_view.as_ref() { + self.workspace.attach(key, view, self.workspace_tabs.clone()); + self.persist_workspace_state(); + } + } + + /// Delete the drafts in `scope` that neither the saved tabs nor an + /// open tab name: those of tabs closed in a session that did not + /// shut down cleanly. + fn retain_drafts(&self, scope: &DraftScope, saved: impl Iterator) { + let mut keep: HashSet = saved.collect(); + keep.extend(self.workspace_tabs.borrow().values().filter_map(|tab| match tab { + WorkspaceTab::Editor(slot) => Some(slot.draft), + _ => None, + })); + self.drafts.retain(scope, keep); + } + + fn reopen_tab( &mut self, - saved: workspace_state::ConnectionWorkspaceState, - drafts: HashMap, + tab: TabRecord, + drafts: &mut HashMap, sender: ComponentSender, - ) { - // Legacy Browse / Structure records are migrated to Table by - // `clamp_connection`, so the load path only has to handle - // Editor + Table. Unknown variants are stripped by clamp too. - for record in &saved.tabs { - match record { - WorkspaceTabRecord::Editor { draft, query } => { - // A record written before drafts carries its text - // inline; the first write moves it into the file. - let text = drafts.get(draft).cloned().unwrap_or_else(|| query.clone()); - self.append_editor_tab_with_draft(Some(text), *draft, sender.clone()); - } - WorkspaceTabRecord::Table { - schema, - table, - mode, - offset, - page_size, - sort_col, - sort_asc, - } => { - // A Structure tab is a record of its own now. - let _ = mode; - self.append_table_tab( - schema.clone(), - table.clone(), - *offset, - *page_size, - match (sort_col, sort_asc) { - (Some(c), Some(a)) => Some((*c, *a)), - _ => None, - }, - sender.clone(), - ); - } - WorkspaceTabRecord::Browse { .. } - | WorkspaceTabRecord::Structure { .. } - | WorkspaceTabRecord::Unknown => { - // Unreachable post-clamp. + ) -> Option { + match tab { + TabRecord::Table { + schema, + table, + offset, + page_size, + sort, + } => self.append_table_tab( + schema, + table, + offset, + page_size, + sort.map(|sort| (sort.column, sort.ascending)), + sender, + ), + TabRecord::Structure { schema, table } => self.append_existing_structure_tab(schema, table, sender), + // A draft that is missing or could not be read opens empty, + // and is not written back until the user types, so its bytes + // stay on disk. + TabRecord::Editor { draft } => self.append_editor_tab_with_draft( + Some(drafts.remove(&draft).unwrap_or_default()), + draft, + DraftWrite::OnChange, + sender, + ), + } + } + + fn show_unreadable_workspace_toast(&self) { + let toast = adw::Toast::builder() + .title(crate::i18n::gettext("Saved tabs could not be read")) + .button_label(crate::i18n::gettext("Reset")) + .action_name("win.reset-workspace-layout") + .build(); + self.toast_overlay.add_toast(toast); + } + + /// Move the unreadable saved tabs aside, so the open ones can be + /// saved in their place. + pub(super) fn on_reset_workspace_layout(&self, sender: ComponentSender) { + let (Some(key), Some(session)) = (self.session_key().cloned(), self.context.active.get()) else { + return; + }; + let origin = Arc::downgrade(&session); + let resetting = self.storage.workspaces().reset(&key); + glib::spawn_future_local(async move { + let outcome = resetting.await; + sender.input(AppMsg::WorkspaceLayoutReset(FromSession::new(origin, outcome))); + }); + } + + pub(super) fn on_workspace_layout_reset(&self, outcome: Result, WorkspaceSaveError>) { + match outcome { + Ok(moved) => { + if let Some(moved) = moved { + tracing::info!(moved = %moved.display(), "moved the unreadable saved tabs aside"); } + self.persist_workspace_state(); + } + Err(error) => { + tracing::warn!(%error, "could not move the saved tabs aside"); + self.show_toast(&crate::i18n::gettext("Saved tabs could not be reset")); } } - if let Some(tab_view) = self.workspace_tab_view.as_ref() - && let Some(page) = tab_view.pages().item(saved.active_idx).and_downcast::() - { - tab_view.set_selected_page(&page); - } - self.workspace_outer_stack.set_visible_child_name("tabs"); } /// Public entry: append a Structure draft tab for the New-Table @@ -231,18 +278,13 @@ impl App { page_size: u64, sort: Option<(usize, bool)>, sender: ComponentSender, - ) { + ) -> Option { self.ensure_workspace_root(sender.clone()); - let Some(tab_view) = self.workspace_tab_view.clone() else { - return; - }; + let tab_view = self.workspace_tab_view.clone()?; let tab_id = Uuid::new_v4(); let connection_id = self.context.active.metadata().map(|metadata| metadata.id); let read_only = self.read_only; - - let Some(session) = self.context.active.get() else { - return; - }; + let session = self.context.active.get()?; let browse = BrowseTab::new(BrowseTabInit { tracker: self.change_trackers.register(tab_id), schema: schema.clone(), @@ -281,6 +323,7 @@ impl App { self.workspace_outer_stack.set_visible_child_name("tabs"); self.refresh_window_title(); self.persist_workspace_state(); + Some(page) } /// Open a Structure (DDL editor) tab for an EXISTING @@ -293,16 +336,12 @@ impl App { schema: Option, table: String, sender: ComponentSender, - ) { + ) -> Option { self.ensure_workspace_root(sender.clone()); - let Some(tab_view) = self.workspace_tab_view.clone() else { - return; - }; + let tab_view = self.workspace_tab_view.clone()?; let tab_id = Uuid::new_v4(); let driver_id = self.driver_id().to_string(); - let Some(session) = self.context.active.get() else { - return; - }; + let session = self.context.active.get()?; let mode = StructureMode::Edit; let structure = StructureTab::new(StructureTabInit { tracker: self.structure_trackers.register(tab_id), @@ -337,27 +376,28 @@ impl App { self.workspace_outer_stack.set_visible_child_name("tabs"); self.refresh_window_title(); self.persist_workspace_state(); + Some(page) } /// Public entry: append an Editor tab with optional initial query. pub(super) fn append_editor_tab(&mut self, initial_query: Option, sender: ComponentSender) { - self.append_editor_tab_with_draft(initial_query, tablepro_storage::DraftId::new(), sender); + self.append_editor_tab_with_draft(initial_query, DraftId::new(), DraftWrite::Now, sender); } /// Append an Editor tab that writes to a given draft, so a restored /// tab keeps the file it was already using. - pub(super) fn append_editor_tab_with_draft( + fn append_editor_tab_with_draft( &mut self, initial_query: Option, - draft: tablepro_storage::DraftId, + draft: DraftId, + write: DraftWrite, sender: ComponentSender, - ) { + ) -> Option { self.ensure_workspace_root(sender.clone()); - let Some(tab_view) = self.workspace_tab_view.clone() else { - return; - }; + let tab_view = self.workspace_tab_view.clone()?; let tab_id = Uuid::new_v4(); - let query = initial_query.clone().unwrap_or_default(); + // Such a tab shows the editor's sample query, which names nothing. + let opened_empty = initial_query.is_none(); let editor = SqlEditor::new(SqlEditorInit { schema_buffer: self.schema_buffer.clone(), initial_query, @@ -370,25 +410,27 @@ impl App { }); connect_editor(&editor, tab_id, sender.input_sender(), &self.toast_overlay.downgrade()); let page = tab_view.append(&editor); - let label = match query.trim().is_empty() { - true => default_editor_tab_label(self.editor_tab_count() + 1), - false => derive_tab_label(&query), + let preview = EditorPreview::read(&editor.buffer()); + let label = if opened_empty || preview.is_blank() { + default_editor_tab_label(self.editor_tab_count() + 1) + } else { + derive_tab_label(&preview.text) }; page.set_title(&label); - if let Some(tip) = editor_tab_tooltip(&query, &label) { + if !opened_empty && let Some(tip) = editor_tab_tooltip(&preview, &label) { page.set_tooltip(&tip); } write_workspace_tab_id(&page, tab_id); - // Track the buffer from the start, so text restored into a tab - // is written back even if the user never types in it. - self.drafts - .schedule(&tablepro_storage::DraftScope::default(), draft, &editor.buffer()); + if write == DraftWrite::Now { + // Text a new tab opens with is written even if the user + // never types in it. + self.drafts.schedule(&self.draft_scope(), draft, &editor.buffer()); + } let slot = EditorTabSlot { editor, page: page.clone(), draft, - query, }; self.workspace_tabs .borrow_mut() @@ -398,6 +440,14 @@ impl App { self.refresh_window_title(); self.rebuild_schema_buffer(); self.persist_workspace_state(); + Some(page) + } + + /// The directory this session's editor drafts live in. + fn draft_scope(&self) -> DraftScope { + self.session_key() + .map(|key| WorkspaceFileName::for_key(key).draft_scope()) + .unwrap_or_default() } pub(super) fn close_workspace_tab_by_id(&mut self, id: Uuid, sender: ComponentSender) { @@ -567,8 +617,7 @@ impl App { slot.editor.shutdown(); // The record no longer names this draft, so the file has // nothing left pointing at it. - self.drafts - .discard(&tablepro_storage::DraftScope::default(), slot.draft); + self.drafts.discard(&self.draft_scope(), slot.draft); } WorkspaceTab::Structure(slot) => { slot.structure.shutdown(); @@ -763,100 +812,12 @@ impl App { dialog.present(Some(&self.window)); } - /// Persist workspace tabs for the active connection. Walks - /// `tab_view.pages()` for canonical display order (HashMap is - /// unordered; user can drag-reorder). - /// Debounced entry point. Coalesces rapid call sites - /// (selection-change, drag-reorder, page-size change, state- - /// changed) into a single write 500ms after the last call. + /// Write the tab list a moment after the last change. pub(super) fn persist_workspace_state(&self) { - // Trailing: a run of keystrokes writes the tab list once at the - // end rather than once at the start and never again. - let workspace_tabs = self.workspace_tabs.clone(); - let tab_view = self.workspace_tab_view.clone(); - // The tabs belong to the session open now, even if the write - // lands after a switch to another one. - let connection_id = self.context.active.metadata().map(|metadata| metadata.id); - self.persist_debouncer.request(move || { - do_persist_workspace_state(connection_id, &workspace_tabs, tab_view.as_ref()); - }); - } - - /// Actual write. Reads the latest tab state, builds the on-disk - /// record, hands it to `workspace_state::save_connection`. Called - /// only via `persist_workspace_state`'s debounced timer or - /// directly from teardown paths that need a synchronous flush. - pub(super) fn do_persist_workspace_state_now(&self) { - let connection_id = self.context.active.metadata().map(|metadata| metadata.id); - do_persist_workspace_state(connection_id, &self.workspace_tabs, self.workspace_tab_view.as_ref()); + self.workspace.request(); } } -fn do_persist_workspace_state( - connection_id: Option, - workspace_tabs: &std::rc::Rc>>, - tab_view: Option<&adw::TabView>, -) { - let Some(connection_id) = connection_id else { - return; - }; - let tabs = workspace_tabs.borrow(); - let Some(tab_view) = tab_view else { - return; - }; - let pages = tab_view.pages(); - let n = pages.n_items(); - let active_page = tab_view.selected_page(); - let mut tab_records: Vec = Vec::with_capacity(n as usize); - let mut active_idx: u32 = 0; - for i in 0..n { - let Some(page) = pages.item(i).and_downcast::() else { - continue; - }; - if active_page.as_ref() == Some(&page) { - active_idx = i; - } - let Some(id) = read_workspace_tab_id(&page) else { - continue; - }; - let Some(slot) = tabs.get(&id) else { continue }; - tab_records.push(match slot { - WorkspaceTab::Editor(s) => WorkspaceTabRecord::Editor { - draft: s.draft, - query: String::new(), - }, - // Structure tabs only exist for the New-Table draft flow - // post-M-1 cleanup; never persist (the table the user is - // drafting doesn't exist yet, so a restore would be a - // meaningless empty form). - WorkspaceTab::Structure(_) => continue, - WorkspaceTab::Table(s) => { - // New-mode draft Tables (no committed table name yet) - // don't survive a disconnect. - if s.table.is_empty() { - continue; - } - let sort = s.browse.current_sort(); - WorkspaceTabRecord::Table { - schema: s.schema.clone(), - table: s.table.clone(), - // A Structure tab is a record of its own now. - mode: crate::services::workspace_state::PersistedTableMode::Data, - offset: s.browse.current_offset(), - page_size: s.browse.page_size(), - sort_col: sort.map(|(c, _)| c), - sort_asc: sort.map(|(_, a)| a), - } - } - }); - } - let conn_state = ConnectionWorkspaceState { - tabs: tab_records, - active_idx, - }; - workspace_state::save_connection(connection_id, conn_state); -} - impl App { /// Single handler for `WorkspaceTabsChanged`. Persists tab state, /// refreshes the window title (so tab switches update the subtitle), @@ -964,8 +925,7 @@ impl App { } pub(super) fn teardown_workspace_tabs(&mut self) { - // The debounced write would run after the state is gone. - self.do_persist_workspace_state_now(); + self.flush_workspace_state(); self.cancel_all_editor_runs(); // The pending edits name rows of the connection going away, so // they cannot be saved after it. @@ -995,6 +955,22 @@ impl App { self.workspace_tabs.borrow_mut().clear(); } + /// Write the tabs as they are now, before the teardown empties them. + /// The process is held until the write lands, so quitting right + /// after a disconnect does not lose it. + fn flush_workspace_state(&self) { + let Some(flushing) = self.workspace.detach() else { + return; + }; + let hold = self.context.hold.acquire(); + glib::spawn_future_local(async move { + if let Err(error) = flushing.await { + tracing::debug!(%error, "the open tabs were not saved"); + } + drop(hold); + }); + } + /// Stop every editor run and close each editor tab's connection, /// for a disconnect that takes the tabs with it. pub(super) fn cancel_all_editor_runs(&self) { @@ -1020,33 +996,27 @@ impl App { } } - /// Editor tab title update on query change. Mirrors what was on - /// editor_tabs.rs::on_editor_tab_query_changed; lives here now since - /// editor tabs are first-class workspace tabs. - pub(super) fn on_editor_tab_query_changed(&self, id: Uuid, query: String) { - let label = if query.trim().is_empty() { + /// Name the tab after the start of its script, and write the script + /// a moment later. Only the first `PREVIEW_CHARS` are read here, so a + /// long script costs nothing to type in. + pub(super) fn on_editor_tab_content_changed(&self, id: Uuid) { + let tabs = self.workspace_tabs.borrow(); + let Some(WorkspaceTab::Editor(slot)) = tabs.get(&id) else { + return; + }; + let buffer = slot.editor.buffer(); + let preview = EditorPreview::read(&buffer); + let label = if preview.is_blank() { crate::i18n::gettext("Empty query") } else { - derive_tab_label(&query) + derive_tab_label(&preview.text) }; - let scheduled = { - let mut tabs = self.workspace_tabs.borrow_mut(); - let Some(WorkspaceTab::Editor(slot)) = tabs.get_mut(&id) else { - return; - }; - slot.page.set_title(&label); - // Pass empty when no extra info to clear any prior tooltip; - // libadwaita treats empty-string as no tooltip. - let tooltip = editor_tab_tooltip(&query, &label).unwrap_or_default(); - slot.page.set_tooltip(&tooltip); - slot.query = query; - (slot.draft, slot.editor.buffer()) - }; - // The buffer is read once when the write fires, not once per - // keystroke, so a long script costs nothing to type in. - self.drafts - .schedule(&tablepro_storage::DraftScope::default(), scheduled.0, &scheduled.1); - self.persist_workspace_state(); + slot.page.set_title(&label); + // libadwaita treats an empty tooltip as none, which clears the + // last one. + slot.page + .set_tooltip(&editor_tab_tooltip(&preview, &label).unwrap_or_default()); + self.drafts.schedule(&self.draft_scope(), slot.draft, &buffer); } pub(super) fn on_editor_tab_run_state_changed(&self, id: Uuid, running: bool) { @@ -1070,7 +1040,7 @@ impl App { pub(super) fn active_editor_query(&self) -> Option { let id = self.selected_workspace_tab_id()?; match self.workspace_tabs.borrow().get(&id) { - Some(WorkspaceTab::Editor(slot)) => Some(slot.query.clone()), + Some(WorkspaceTab::Editor(slot)) => Some(whole_text(&slot.editor.buffer())), _ => None, } } @@ -1237,8 +1207,7 @@ fn overview_editor_tab( let slot = EditorTabSlot { editor, page: page.clone(), - draft: tablepro_storage::DraftId::new(), - query: String::new(), + draft: DraftId::new(), }; workspace_tabs.borrow_mut().insert(tab_id, WorkspaceTab::Editor(slot)); input.emit(AppMsg::WorkspaceTabsChanged); @@ -1272,7 +1241,9 @@ fn browse_tab_tooltip(schema: Option<&str>, table: &str, label: &str) -> Option< fn describe_closed_tab(slot: &WorkspaceTab) -> Option { match slot { - WorkspaceTab::Editor(s) => Some(ClosedTabDescriptor::Editor { query: s.query.clone() }), + WorkspaceTab::Editor(s) => Some(ClosedTabDescriptor::Editor { + query: whole_text(&s.editor.buffer()), + }), WorkspaceTab::Table(s) => Some(ClosedTabDescriptor::Table { schema: s.schema.clone(), table: s.table.clone(), @@ -1338,29 +1309,75 @@ impl App { } } -/// At most 200 characters of the query, and none when the label -/// already shows them. -fn editor_tab_tooltip(query: &str, label: &str) -> Option { - let q = query.trim(); - if q.is_empty() { +const PREVIEW_CHARS: i32 = 200; + +/// The start of an editor's script, read without copying the rest. +struct EditorPreview { + text: String, + truncated: bool, +} + +impl EditorPreview { + fn read(buffer: >k::TextBuffer) -> Self { + let end = buffer.iter_at_offset(PREVIEW_CHARS); + Self { + text: buffer.text(&buffer.start_iter(), &end, false).to_string(), + truncated: !end.is_end(), + } + } + + fn is_blank(&self) -> bool { + !self.truncated && self.text.trim().is_empty() + } +} + +/// The start of the script, and none when the label already shows it. +fn editor_tab_tooltip(preview: &EditorPreview, label: &str) -> Option { + let text = preview.text.trim(); + if text.is_empty() { return None; } - // Use char_indices().nth(200) to walk only the first 201 chars - // instead of materialising the whole query as a Vec via - // chars().count(). For a multi-megabyte SQL dump this avoids an - // O(n) scan when only a 200-char preview matters. - let mut iter = q.char_indices(); - let mut last_idx = 0; - for _ in 0..200 { - match iter.next() { - Some((i, c)) => last_idx = i + c.len_utf8(), - None => { - return if q == label { None } else { Some(q.to_string()) }; - } - } + let tooltip = match preview.truncated { + true => format!("{}…", preview.text.trim_start()), + false => text.to_owned(), + }; + (tooltip != label).then_some(tooltip) +} + +/// The whole script, for the few places that need all of it at once. +fn whole_text(buffer: >k::TextBuffer) -> String { + buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string() +} + +/// Whether a new editor tab's draft is written at once or only after +/// the user changes it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DraftWrite { + Now, + OnChange, +} + +pub(crate) struct LoadedWorkspace { + key: SessionKey, + load: WorkspaceLoad, + drafts: HashMap, +} + +impl std::fmt::Debug for LoadedWorkspace { + // The drafts are the user's SQL, which stays out of the journal. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LoadedWorkspace") + .field("key", &self.key) + .field("drafts", &self.drafts.len()) + .finish_non_exhaustive() } - let preview = format!("{}…", &q[..last_idx]); - if preview == label { None } else { Some(preview) } +} + +fn saved_drafts(record: &WorkspaceRecord) -> impl Iterator + '_ { + record.tabs.iter().filter_map(|tab| match tab { + TabRecord::Editor { draft } => Some(*draft), + _ => None, + }) } /// The structure tab's signals, as this window's inputs for the tab. @@ -1433,8 +1450,8 @@ fn connect_editor( editor.connect_run_state_changed(move |_, running| { run_state.emit(AppMsg::EditorTabRunStateChanged(tab_id, running)); }); - let query = input.clone(); - editor.connect_query_changed(move |_, text| query.emit(AppMsg::EditorTabQueryChanged(tab_id, text))); + let content = input.clone(); + editor.connect_content_changed(move |_| content.emit(AppMsg::EditorTabContentChanged(tab_id))); let clipboard = input.clone(); editor.connect_copy_to_clipboard(move |_, text| clipboard.emit(AppMsg::CopyToClipboard(text))); let toast = input.clone(); @@ -1599,6 +1616,32 @@ mod tests { assert!(!button.is_visible(), "still shown after closing back to one tab"); } + #[gtk4::test] + fn an_editor_tab_is_named_from_the_start_of_its_script_only() { + let buffer = gtk::TextBuffer::new(None); + buffer.set_text(&format!("SELECT 1;\n{}", "-- é\n".repeat(10_000))); + + let preview = EditorPreview::read(&buffer); + + let limit = usize::try_from(PREVIEW_CHARS).expect("a positive limit"); + assert_eq!(preview.text.chars().count(), limit); + assert!(preview.truncated); + let tooltip = editor_tab_tooltip(&preview, "SELECT 1;").expect("a tooltip"); + assert!(tooltip.starts_with("SELECT 1;"), "{tooltip}"); + assert!(tooltip.ends_with('…'), "{tooltip}"); + } + + #[gtk4::test] + fn a_script_its_label_shows_whole_has_no_tooltip() { + let buffer = gtk::TextBuffer::new(None); + buffer.set_text("SELECT 1;"); + + let preview = EditorPreview::read(&buffer); + + assert!(!preview.truncated); + assert_eq!(editor_tab_tooltip(&preview, &derive_tab_label(&preview.text)), None); + } + #[gtk4::test] fn an_editor_tab_leaves_the_toast_overlay_free() { crate::register_test_resources(); diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index b506ba6edb..a9f5e8f382 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -79,9 +79,9 @@ impl ObjectImpl for SqlEditor { Signal::builder("run-state-changed") .param_types([bool::static_type()]) .build(), - Signal::builder("query-changed") - .param_types([String::static_type()]) - .build(), + // No text: the buffer can be megabytes, and a listener + // reads only what it needs from it. + Signal::builder("content-changed").build(), Signal::builder("copy-to-clipboard") .param_types([String::static_type()]) .build(), diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index d229e43e15..8a908fade0 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -254,11 +254,11 @@ impl SqlEditor { ) } - pub fn connect_query_changed(&self, f: F) -> glib::SignalHandlerId { + pub fn connect_content_changed(&self, f: F) -> glib::SignalHandlerId { self.connect_closure( - "query-changed", + "content-changed", false, - glib::closure_local!(move |editor: &Self, text: String| f(editor, text)), + glib::closure_local!(move |editor: &Self| f(editor)), ) } @@ -551,9 +551,9 @@ impl SqlEditor { )); let weak = self.downgrade(); - buffer.connect_changed(move |buffer| { + buffer.connect_changed(move |_| { if let Some(editor) = weak.upgrade() { - editor.emit_by_name::<()>("query-changed", &[&buffer_text(buffer)]); + editor.emit_by_name::<()>("content-changed", &[]); } }); diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 06026c6613..c966110e3d 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -243,6 +243,19 @@ fn format_keeps_the_old_text_one_undo_away() { assert_eq!(buffer_text(&harness.editor.buffer()), "select a,b from t where x=1"); } +#[gtk4::test] +fn typing_emits_content_changed_without_text() { + let harness = editor_with(answering(), "SELECT 1"); + let changes = SignalLog::connect(&harness.editor, "content-changed", |values| values.len()); + let buffer = harness.editor.buffer(); + + buffer.insert(&mut buffer.end_iter(), " "); + buffer.insert(&mut buffer.end_iter(), "2"); + + // One emission per change, carrying the editor and nothing else. + assert_eq!(changes.take(), [1, 1]); +} + #[gtk4::test] fn dropped_non_utf8_file_is_rejected() { let harness = editor_with(answering(), "SELECT 1"); diff --git a/linux/crates/storage/src/paths.rs b/linux/crates/storage/src/paths.rs index d7633eda03..baa243ce75 100644 --- a/linux/crates/storage/src/paths.rs +++ b/linux/crates/storage/src/paths.rs @@ -50,10 +50,6 @@ impl StoragePaths { self.state.join("workspaces") } - pub fn workspace_state_file(&self) -> PathBuf { - self.state.join("workspace_state.json") - } - pub fn column_widths_file(&self) -> PathBuf { self.config.join("column_widths.json") } diff --git a/linux/docs/storage.md b/linux/docs/storage.md index 9032ce970e..846454d1b6 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -84,7 +84,7 @@ base directories and their fallbacks. | `column_widths_file()` | `$XDG_CONFIG_HOME//column_widths.json` | | `filter_settings_file()` | `$XDG_CONFIG_HOME//filter_settings.json` | | `history_database()` | `$XDG_STATE_HOME//history.db` | -| `workspace_state_file()` | `$XDG_STATE_HOME//workspace_state.json` | +| `workspaces_dir()` | `$XDG_STATE_HOME//workspaces` | | `drafts_dir()` | `$XDG_DATA_HOME//drafts` | | `instance_lock()` | `$XDG_RUNTIME_DIR/app//tablepro.lock` | diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 11581bdbfc..6bce0f9c1a 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -39,7 +39,6 @@ crates/app/src/services/touch.rs crates/app/src/services/tracker_delta.rs crates/app/src/services/tracker_edit.rs crates/app/src/services/undo_op.rs -crates/app/src/services/workspace_state.rs crates/app/src/storage.rs crates/app/src/test_support.rs crates/app/src/test_support/accessible_label.rs @@ -60,6 +59,7 @@ crates/app/src/ui/app/row_ops.rs crates/app/src/ui/app/status_pages.rs crates/app/src/ui/app/structure.rs crates/app/src/ui/app/teardown_guard.rs +crates/app/src/ui/app/workspace_persistence.rs crates/app/src/ui/app/workspace_tabs.rs crates/app/src/ui/browse_tab/browse_command.rs crates/app/src/ui/browse_tab/browse_request_kind.rs @@ -148,6 +148,15 @@ crates/app/src/ui/window_geometry.rs crates/app/src/workspace/draft_writer.rs crates/app/src/workspace/mod.rs crates/app/src/workspace/persist_debouncer.rs +crates/app/src/workspace/sort_record.rs +crates/app/src/workspace/tab_record.rs +crates/app/src/workspace/workspace_file_name.rs +crates/app/src/workspace/workspace_load.rs +crates/app/src/workspace/workspace_load_error.rs +crates/app/src/workspace/workspace_record.rs +crates/app/src/workspace/workspace_save_error.rs +crates/app/src/workspace/workspace_store.rs +crates/app/src/workspace/write_job.rs data/app.tablepro.TablePro.desktop.in.in data/app.tablepro.TablePro.gschema.xml data/app.tablepro.TablePro.metainfo.xml.in.in From 7ad8b070ba0c94192ffc891edf363c872f0d315a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 22:49:49 +0700 Subject: [PATCH 106/178] fix(linux): write exported connections off the GTK thread CI now fails on std::fs outside tests in ui/app and ui/editor. --- .github/workflows/build-linux.yml | 11 +++++++++++ linux/crates/app/src/ui/app/connection.rs | 22 +++++++++++++--------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 27aed09747..7ad24e5f7c 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -106,6 +106,17 @@ jobs: echo "::error::crates/app/src/services must not hold thread_local! state" exit 1 fi + - name: No blocking file I/O in the window and editor code + # This code runs on the GTK thread, where a slow disk or a + # network mount freezes the window. Tests may use std::fs. + run: | + found=$(find crates/app/src/ui/app crates/app/src/ui/editor -name '*.rs' ! -name tests.rs -print0 \ + | xargs -0 awk '/^mod tests \{/ { nextfile } /std::fs::/ { print FILENAME ":" FNR ": " $0 }') + if [ -n "$found" ]; then + echo "$found" + echo "::error::crates/app/src/ui/app and crates/app/src/ui/editor must not call std::fs" + exit 1 + fi - name: Clippy run: meson devenv -C _build cargo clippy --locked --workspace --all-targets -- -D warnings - name: Clippy without default features diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index b868902323..7b6ac8499a 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -688,15 +688,19 @@ impl App { let input = sender.input_sender().clone(); dialog.save(Some(&self.window), gio::Cancellable::NONE, move |outcome| { let Ok(file) = outcome else { return }; - let Some(path) = file.path() else { return }; - let message = match std::fs::write(&path, &bytes) { - Ok(()) => crate::i18n::gettext("Connections exported."), - Err(error) => { - tracing::warn!(%error, path = %path.display(), "could not write the exported connections"); - crate::i18n::gettext("The connections could not be written to that file.") - } - }; - let _ = input.send(AppMsg::ShowToast(message)); + glib::spawn_future_local(async move { + let written = file + .replace_contents_future(bytes, None, false, gio::FileCreateFlags::NONE) + .await; + let message = match written { + Ok(_) => crate::i18n::gettext("Connections exported."), + Err((_, error)) => { + tracing::warn!(%error, uri = %file.uri(), "could not write the exported connections"); + crate::i18n::gettext("The connections could not be written to that file.") + } + }; + let _ = input.send(AppMsg::ShowToast(message)); + }); }); } From b1fc5888af65f3c565e470807f5c711957a174b4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 22:53:06 +0700 Subject: [PATCH 107/178] feat(linux-core): list completion keywords and bare names per grammar --- linux/crates/core/src/sql_syntax/keywords.rs | 238 +++++++++++++++++++ linux/crates/core/src/sql_syntax/mod.rs | 1 + 2 files changed, 239 insertions(+) create mode 100644 linux/crates/core/src/sql_syntax/keywords.rs diff --git a/linux/crates/core/src/sql_syntax/keywords.rs b/linux/crates/core/src/sql_syntax/keywords.rs new file mode 100644 index 0000000000..58640e1e4a --- /dev/null +++ b/linux/crates/core/src/sql_syntax/keywords.rs @@ -0,0 +1,238 @@ +use crate::sql_syntax::SqlGrammar; + +const COMMON: &[&str] = &[ + "ADD", + "ALL", + "ALTER", + "AND", + "ANY", + "AS", + "ASC", + "AVG", + "BEGIN", + "BETWEEN", + "BY", + "CASE", + "CAST", + "CHECK", + "COALESCE", + "COLUMN", + "COMMIT", + "CONSTRAINT", + "COUNT", + "CREATE", + "CROSS", + "DEFAULT", + "DELETE", + "DESC", + "DISTINCT", + "DROP", + "ELSE", + "END", + "EXCEPT", + "EXISTS", + "FALSE", + "FOREIGN", + "FROM", + "FULL", + "GROUP", + "HAVING", + "IN", + "INDEX", + "INNER", + "INSERT", + "INTERSECT", + "INTO", + "IS", + "JOIN", + "KEY", + "LEFT", + "LIKE", + "MAX", + "MIN", + "NOT", + "NULL", + "NULLIF", + "ON", + "OR", + "ORDER", + "OUTER", + "PRIMARY", + "REFERENCES", + "RIGHT", + "ROLLBACK", + "SELECT", + "SET", + "SUM", + "TABLE", + "THEN", + "TRUE", + "TRUNCATE", + "UNION", + "UNIQUE", + "UPDATE", + "USING", + "VALUES", + "VIEW", + "WHEN", + "WHERE", + "WITH", +]; + +const POSTGRESQL: &[&str] = &[ + "ANALYZE", + "CONFLICT", + "DO", + "EXPLAIN", + "ILIKE", + "LATERAL", + "LIMIT", + "MATERIALIZED", + "NOTHING", + "OFFSET", + "RETURNING", + "SCHEMA", + "SEQUENCE", + "SIMILAR", + "USER", + "VACUUM", +]; + +const MYSQL: &[&str] = &[ + "AUTO_INCREMENT", + "DATABASE", + "DESCRIBE", + "DUPLICATE", + "EXPLAIN", + "IGNORE", + "LIMIT", + "OFFSET", + "REGEXP", + "REPLACE", + "SHOW", + "USE", +]; + +const SQLITE: &[&str] = &[ + "ATTACH", + "AUTOINCREMENT", + "DETACH", + "EXPLAIN", + "GLOB", + "IGNORE", + "LIMIT", + "OFFSET", + "PRAGMA", + "REPLACE", + "RETURNING", + "VACUUM", + "WITHOUT", +]; + +const MSSQL: &[&str] = &[ + "DECLARE", "EXEC", "FETCH", "IDENTITY", "MERGE", "NEXT", "OFFSET", "ONLY", "OUTPUT", "ROWS", "TOP", "USE", +]; + +const CLICKHOUSE: &[&str] = &[ + "ARRAY", "DATABASE", "DESCRIBE", "ENGINE", "EXPLAIN", "FINAL", "FORMAT", "LIMIT", "OFFSET", "OPTIMIZE", "PREWHERE", + "SAMPLE", "SETTINGS", "SHOW", +]; + +impl SqlGrammar { + /// The keywords the editor completes for this grammar, in upper case. + pub fn keywords(self) -> impl Iterator { + let own = match self { + Self::PostgreSql => POSTGRESQL, + Self::MySql => MYSQL, + Self::Sqlite => SQLITE, + Self::MsSql => MSSQL, + Self::ClickHouse => CLICKHOUSE, + }; + COMMON.iter().chain(own).copied() + } + + /// Whether `name` reads as the same identifier written without + /// quotes. + /// + /// PostgreSQL folds an unquoted name to lower case, so a capital + /// changes the name there. A keyword the editor completes is quoted + /// too, because most of them are reserved somewhere. + pub fn is_bare_identifier(self, name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + let letter = |c: char| match self { + Self::ClickHouse => c.is_ascii_alphabetic(), + _ => c.is_alphabetic(), + }; + let start = letter(first) || first == '_' || (self == Self::MsSql && first == '#'); + let part = |c: char| { + letter(c) + || c.is_ascii_digit() + || c == '_' + || (c == '$' && self != Self::ClickHouse) + || (self == Self::MsSql && matches!(c, '#' | '@')) + }; + let folds = self == Self::PostgreSql && name.chars().any(char::is_uppercase); + start && chars.all(part) && !folds && !self.keywords().any(|keyword| keyword.eq_ignore_ascii_case(name)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn has(grammar: SqlGrammar, word: &str) -> bool { + grammar.keywords().any(|keyword| keyword == word) + } + + #[test] + fn every_grammar_has_the_common_words_and_its_own() { + for grammar in SqlGrammar::ALL { + assert!(has(grammar, "SELECT"), "{grammar:?}"); + } + assert!(has(SqlGrammar::PostgreSql, "RETURNING")); + assert!(has(SqlGrammar::MsSql, "TOP")); + assert!(!has(SqlGrammar::MsSql, "LIMIT")); + assert!(has(SqlGrammar::ClickHouse, "PREWHERE")); + } + + #[test] + fn keywords_are_upper_case_and_listed_once() { + for grammar in SqlGrammar::ALL { + let mut seen = std::collections::HashSet::new(); + for keyword in grammar.keywords() { + assert_eq!(keyword, keyword.to_ascii_uppercase()); + assert!(seen.insert(keyword), "{keyword} twice for {grammar:?}"); + } + } + } + + #[test] + fn plain_names_stay_bare() { + for grammar in SqlGrammar::ALL { + assert!(grammar.is_bare_identifier("users"), "{grammar:?}"); + assert!(grammar.is_bare_identifier("order_items_2"), "{grammar:?}"); + assert!(grammar.is_bare_identifier("_draft"), "{grammar:?}"); + } + assert!(SqlGrammar::PostgreSql.is_bare_identifier("đơn_hàng")); + assert!(SqlGrammar::MySql.is_bare_identifier("Users")); + assert!(SqlGrammar::MsSql.is_bare_identifier("#scratch")); + } + + #[test] + fn names_that_read_differently_bare_need_quotes() { + for grammar in SqlGrammar::ALL { + assert!(!grammar.is_bare_identifier(""), "{grammar:?}"); + assert!(!grammar.is_bare_identifier("order items"), "{grammar:?}"); + assert!(!grammar.is_bare_identifier("2fa"), "{grammar:?}"); + assert!(!grammar.is_bare_identifier("order"), "{grammar:?}"); + assert!(!grammar.is_bare_identifier("a-b"), "{grammar:?}"); + } + assert!(!SqlGrammar::PostgreSql.is_bare_identifier("Users")); + assert!(!SqlGrammar::PostgreSql.is_bare_identifier("user")); + assert!(!SqlGrammar::ClickHouse.is_bare_identifier("đơn")); + assert!(!SqlGrammar::ClickHouse.is_bare_identifier("a$b")); + } +} diff --git a/linux/crates/core/src/sql_syntax/mod.rs b/linux/crates/core/src/sql_syntax/mod.rs index 578fac6610..e6de2120b5 100644 --- a/linux/crates/core/src/sql_syntax/mod.rs +++ b/linux/crates/core/src/sql_syntax/mod.rs @@ -1,5 +1,6 @@ mod errors; mod grammar; +mod keywords; mod literal_token; mod parse_expression; mod parse_type; From 7b0c53fc6c071993de419169ad8a4f64c83b511b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 22:53:19 +0700 Subject: [PATCH 108/178] feat(linux-editor): complete tables, columns, keywords from the catalog GtkSourceView's fuzzy matcher looks needle characters up with strchr, so a non-ASCII needle never matches; fuzzy.rs keeps its scoring over whole characters instead of wrapping it. --- linux/ARCHITECTURE.md | 2 +- linux/CHANGELOG.md | 1 + linux/crates/app/src/ui/app/connection.rs | 13 +- linux/crates/app/src/ui/app/mod.rs | 10 +- linux/crates/app/src/ui/app/row_ops.rs | 1 - linux/crates/app/src/ui/app/structure.rs | 4 +- linux/crates/app/src/ui/app/workspace_tabs.rs | 23 - linux/crates/app/src/ui/browse_tab/imp.rs | 1 - linux/crates/app/src/ui/browse_tab/mod.rs | 9 - .../src/ui/editor/completion/catalog_read.rs | 129 +++++ .../ui/editor/completion/completion_scope.rs | 533 ++++++++++++++++++ .../app/src/ui/editor/completion/fuzzy.rs | 151 +++++ .../app/src/ui/editor/completion/mod.rs | 14 + .../src/ui/editor/completion/proposal_kind.rs | 88 +++ .../completion/sql_completion_provider.rs | 240 ++++++++ .../completion/sql_completion_provider_imp.rs | 98 ++++ .../src/ui/editor/completion/sql_proposal.rs | 111 ++++ .../ui/editor/completion/sql_proposal_imp.rs | 27 + .../app/src/ui/editor/completion/tests.rs | 384 +++++++++++++ linux/crates/app/src/ui/editor/mod.rs | 43 +- linux/crates/app/src/ui/editor/tests.rs | 2 - linux/crates/app/src/ui/icons.rs | 6 + linux/po/POTFILES.in | 10 + 23 files changed, 1810 insertions(+), 90 deletions(-) create mode 100644 linux/crates/app/src/ui/editor/completion/catalog_read.rs create mode 100644 linux/crates/app/src/ui/editor/completion/completion_scope.rs create mode 100644 linux/crates/app/src/ui/editor/completion/fuzzy.rs create mode 100644 linux/crates/app/src/ui/editor/completion/mod.rs create mode 100644 linux/crates/app/src/ui/editor/completion/proposal_kind.rs create mode 100644 linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs create mode 100644 linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs create mode 100644 linux/crates/app/src/ui/editor/completion/sql_proposal.rs create mode 100644 linux/crates/app/src/ui/editor/completion/sql_proposal_imp.rs create mode 100644 linux/crates/app/src/ui/editor/completion/tests.rs diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index b3a8a734cf..b60e99a785 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -193,7 +193,7 @@ On Linux the system store is the distribution's CA bundle. Under Flatpak it is t A window holds one `Session`. Every use of its connection goes through the `OperationRunner`, which adds no timer of its own: the deadline travels in `CallOptions` so the driver stops the statement on the server. The session's monitor probes every 30 s and calls the connection lost after three failed probes in a row, or as soon as `Connection::lost()` fires, which a driver resolves only when the link is gone, never for one dead backend in its pool. It then reconnects on a backoff from 5 s to 60 s. A failure that a retry cannot fix (authentication, TLS, configuration, a host key to review) parks the session until the user acts. A read that failed because its connection was lost runs once more on the replacement link; a save never does. -The session's `SchemaCatalog` holds what the window knows about the database: the schemas, each scope's objects and each table's columns. Callers that ask for the same thing at once share one fetch, a fetch runs as its own task so a view that closes does not stop it, and a failed refresh keeps the list it had. A reconnect commits the catalog the new connection read and marks the rest stale; `invalidate_table` does the same for one table after a schema change. The sidebar and the editor's completion words follow the default schema's list through a watch the window drops before the session closes. +The session's `SchemaCatalog` holds what the window knows about the database: the schemas, each scope's objects and each table's columns. Callers that ask for the same thing at once share one fetch, a fetch runs as its own task so a view that closes does not stop it, and a failed refresh keeps the list it had. A reconnect commits the catalog the new connection read and marks the rest stale; `invalidate_table` does the same for one table after a schema change. The sidebar follows the default schema's list through a watch the window drops before the session closes. The editor's completion reads the catalog when it opens: the default schema's objects, a named schema's objects after `schema.`, and the columns of the tables the statement names. ## Workspace tab system diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 5fd77c9a4e..b8ae81236d 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Security group in the connect dialog: encryption mode, CA certificate, client certificate and TLS server name. - SQLite file chooser in the connect dialog, with New Database to create a file. - Results list in the SQL editor with one entry per statement, its rows affected or error, and the open transaction. +- SQL editor completion of tables, views and columns from the database, resolving aliases and matching fuzzily. ### Fixed diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index de93fce359..c48d308738 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -78,9 +78,8 @@ pub(crate) struct SessionLinks { key: SessionKey, _claim: SessionClaim, _health: WatchForwarder, - /// Feeds the sidebar and the editor's completion words. Dropped - /// before the session shuts down, so a list read for a session the - /// window let go of never repaints it. + /// Feeds the sidebar. Dropped before the session shuts down, so a + /// list read for a session the window let go of never repaints it. _catalog_watch: WatchForwarder, } @@ -444,7 +443,6 @@ impl App { self.session_links = None; close_session(&self.context.active, &*self.context.hold); self.reconnect_banner.set_revealed(false); - self.schema_buffer.set_text(crate::ui::editor::SQL_KEYWORDS); self.current_driver_id = None; self.read_only = false; self.read_only_badge.set_visible(false); @@ -826,13 +824,8 @@ impl App { }); } - /// The session's objects in the sidebar and among the editor's - /// completion words. pub(super) fn show_objects(&mut self, objects: &[ObjectInfo]) { - let objects = browsable(objects.to_vec()); - self.table_names = objects.iter().map(|object| object.name.clone()).collect(); - self.repopulate_sidebar(objects); - self.rebuild_schema_buffer(); + self.repopulate_sidebar(browsable(objects.to_vec())); } /// Only what the grid can page through goes in the sidebar. diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 39034f3e66..136a72aa71 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -22,7 +22,7 @@ use uuid::Uuid; use super::browse_tab::{BrowseTab, BrowseTabInput}; use super::connect_dialog::ConnectDialog; use super::connection_row::{ConnectionRow, ConnectionRowOutput}; -use super::editor::{SqlEditor, build_schema_buffer}; +use super::editor::SqlEditor; use super::history_dialog::HistoryDialog; use super::sidebar_row::{SidebarRow, SidebarRowOutput}; use super::welcome_view::{WelcomeView, WelcomeViewInit, WelcomeViewOutput}; @@ -123,15 +123,12 @@ pub struct App { /// This window's pending DDL, one tracker per Structure tab. structure_trackers: std::rc::Rc, dialog: Option, - schema_buffer: gtk::TextBuffer, history_dialog: Option>, saved_queries_dialog: Option>, import_dialog: Option>, welcome_view: Controller, /// Driver id is connection-wide, not per-tab. current_driver_id: Option, - /// The table names the editor completes; the tabs add their columns. - table_names: Vec, /// Read-only flag is connection-wide; fanned out to every BrowseTab /// when toggled. read_only: bool, @@ -415,8 +412,6 @@ pub enum AppMsg { }, // ── Workspace tab routing ──────────────────────────────────────── - /// Any browse tab's columns changed; rebuild editor schema buffer. - WorkspaceSchemaWordsChanged, /// User clicked the close-X on any workspace tab. WorkspaceTabClosed(Uuid), /// Tab right-click "Close Other Tabs" → close every tab except @@ -1216,13 +1211,11 @@ impl SimpleComponent for App { change_trackers, structure_trackers, dialog: None, - schema_buffer: build_schema_buffer(), history_dialog: None, saved_queries_dialog: None, import_dialog: None, welcome_view, current_driver_id: None, - table_names: Vec::new(), read_only: false, saved_connections: Vec::new(), connected: false, @@ -1305,7 +1298,6 @@ impl SimpleComponent for App { open_mode, } => self.dispatch_select_table(schema, name, open_mode, sender), AppMsg::WorkspaceTabsChanged => self.on_workspace_tabs_changed(), - AppMsg::WorkspaceSchemaWordsChanged => self.rebuild_schema_buffer(), AppMsg::ExecuteBrowseTransaction { tab_id, table, diff --git a/linux/crates/app/src/ui/app/row_ops.rs b/linux/crates/app/src/ui/app/row_ops.rs index 4ae874c610..5c68af56f5 100644 --- a/linux/crates/app/src/ui/app/row_ops.rs +++ b/linux/crates/app/src/ui/app/row_ops.rs @@ -104,7 +104,6 @@ impl App { touched.and_then(|rows| i64::try_from(rows).ok()), None, ); - sender.input(AppMsg::WorkspaceSchemaWordsChanged); sender.input(AppMsg::SaveCompletedForTab(tab_id)); } Err(error) => { diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index 76d4272012..b5cdb6abb6 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -474,8 +474,8 @@ impl App { }); } - /// The sidebar and the editor's completion words follow the - /// session's object list. A failed refresh keeps both as they were. + /// The sidebar follows the session's object list. A failed refresh + /// keeps it as it was. pub(super) fn on_catalog_changed(&mut self, snapshot: &tablepro_session::ObjectListSnapshot) { self.show_objects(&snapshot.objects); } diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index 7f703c5e36..3b2101bdb5 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -37,7 +37,6 @@ impl App { } fn build_workspace_root(&mut self, sender: ComponentSender) { - let schema_buffer = self.schema_buffer.clone(); let settings = self.settings.clone(); let history = self.history.store(); let tasks = self.tasks.clone(); @@ -45,7 +44,6 @@ impl App { let hold = self.context.hold.clone(); let close_timeout = self.context.close_timeout(); let editor_init = move || SqlEditorInit { - schema_buffer: schema_buffer.clone(), initial_query: None, settings: settings.clone(), history: history.clone(), @@ -359,7 +357,6 @@ impl App { let tab_id = Uuid::new_v4(); let query = initial_query.clone().unwrap_or_default(); let editor = SqlEditor::new(SqlEditorInit { - schema_buffer: self.schema_buffer.clone(), initial_query, settings: self.settings.clone(), history: self.history.store(), @@ -396,7 +393,6 @@ impl App { tab_view.set_selected_page(&page); self.workspace_outer_stack.set_visible_child_name("tabs"); self.refresh_window_title(); - self.rebuild_schema_buffer(); self.persist_workspace_state(); } @@ -592,7 +588,6 @@ impl App { if self.workspace_tabs.borrow().is_empty() { self.workspace_outer_stack.set_visible_child_name("empty"); } - self.rebuild_schema_buffer(); self.refresh_window_title(); } @@ -914,19 +909,6 @@ impl App { Some((schema.map(str::to_owned), table.to_string())) } - pub(super) fn rebuild_schema_buffer(&self) { - let mut words: Vec = self.table_names.clone(); - let tabs = self.workspace_tabs.borrow(); - for tab in tabs.values() { - if let Some(browse) = tab.browse_tab() { - words.extend(browse.columns().into_iter().map(|column| column.name)); - } - } - words.sort_unstable(); - words.dedup(); - crate::ui::editor::update_schema_buffer(&self.schema_buffer, &words); - } - pub(super) fn sidebar_schemas_distinct(&self) -> usize { let schemas = self.sidebar_schemas.borrow(); let distinct: std::collections::BTreeSet<&str> = schemas.iter().filter_map(|s| s.as_deref()).collect(); @@ -1385,8 +1367,6 @@ fn connect_structure(structure: &StructureTab, tab_id: Uuid, input: &relm4::Send fn connect_browse(browse: &BrowseTab, tab_id: Uuid, input: &relm4::Sender) { let state = input.clone(); browse.connect_state_changed(move |_| state.emit(AppMsg::WorkspaceTabsChanged)); - let columns = input.clone(); - browse.connect_columns_changed(move |_| columns.emit(AppMsg::WorkspaceSchemaWordsChanged)); let copy_row = input.clone(); browse.connect_copy_row_as_insert(move |_, row_position| { copy_row.emit(AppMsg::CopyRowAsInsert { tab_id, row_position }); @@ -1495,11 +1475,9 @@ mod tests { ) -> WorkspaceChrome { adw::init().expect("libadwaita initialises under the test backend"); crate::register_test_resources(); - let schema_buffer = crate::ui::editor::build_schema_buffer(); let settings = settings.get().clone(); let tasks = runtime.tasks(); let editor_init = move || SqlEditorInit { - schema_buffer: schema_buffer.clone(), initial_query: None, settings: settings.clone(), history: None, @@ -1605,7 +1583,6 @@ mod tests { let runtime = crate::test_support::test_runtime(); let settings = crate::test_support::MemorySettings::new(); let editor = SqlEditor::new(SqlEditorInit { - schema_buffer: crate::ui::editor::build_schema_buffer(), initial_query: None, settings: settings.get().clone(), history: None, diff --git a/linux/crates/app/src/ui/browse_tab/imp.rs b/linux/crates/app/src/ui/browse_tab/imp.rs index fd783c868f..1e8e8ac0eb 100644 --- a/linux/crates/app/src/ui/browse_tab/imp.rs +++ b/linux/crates/app/src/ui/browse_tab/imp.rs @@ -166,7 +166,6 @@ impl ObjectImpl for BrowseTab { SIGNALS.get_or_init(|| { vec![ Signal::builder("state-changed").build(), - Signal::builder("columns-changed").build(), Signal::builder("copy-row-as-insert") .param_types([u32::static_type()]) .build(), diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index eae45e2c93..d0ee19e53d 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -304,10 +304,6 @@ impl BrowseTab { self.connect_closure("state-changed", false, glib::closure_local!(move |tab: &Self| f(tab))) } - pub fn connect_columns_changed(&self, f: F) -> glib::SignalHandlerId { - self.connect_closure("columns-changed", false, glib::closure_local!(move |tab: &Self| f(tab))) - } - pub fn connect_copy_row_as_insert(&self, f: F) -> glib::SignalHandlerId { self.connect_closure( "copy-row-as-insert", @@ -372,7 +368,6 @@ impl BrowseTab { fn emit_output(&self, output: BrowseTabOutput) { match output { BrowseTabOutput::StateChanged => self.emit_by_name::<()>("state-changed", &[]), - BrowseTabOutput::ColumnsChanged => self.emit_by_name::<()>("columns-changed", &[]), BrowseTabOutput::CopyRowAsInsert { row_position } => { self.emit_by_name::<()>("copy-row-as-insert", &[&row_position]); } @@ -711,9 +706,6 @@ pub(crate) enum BrowseTabOutput { /// Something the window keeps for the tab changed: the page, the /// sort, the page size. StateChanged, - /// The columns the tab shows changed, which the editor's completion - /// words follow. - ColumnsChanged, CopyRowAsInsert { row_position: u32, }, @@ -1411,7 +1403,6 @@ impl BrowseModel { if let Some(strip) = self.filter_strip.as_ref() { strip.update_columns(columns); } - fx.output(BrowseTabOutput::ColumnsChanged); self.render_grid_if_ready(fx); } diff --git a/linux/crates/app/src/ui/editor/completion/catalog_read.rs b/linux/crates/app/src/ui/editor/completion/catalog_read.rs new file mode 100644 index 0000000000..4c02ebaa8c --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/catalog_read.rs @@ -0,0 +1,129 @@ +use std::sync::Arc; + +use tablepro_core::meta::{ObjectInfo, ObjectScope, SchemaInfo}; +use tablepro_core::{ColumnInfo, TableRef}; +use tablepro_session::{OperationError, Session}; + +use super::completion_scope::CompletionScope; + +/// What the session's catalog holds for one completion. +#[derive(Debug, Default)] +pub struct CatalogAnswer { + pub schemas: Option>, + pub objects: Option>, + pub columns: Vec<(TableRef, Arc<[ColumnInfo]>)>, +} + +/// Read what `scope` offers. The catalog shares each read with every +/// other view and keeps what it read, so typing does not reach the +/// server again; a list that cannot be read is left out. +pub async fn read(session: Arc, scope: CompletionScope) -> CatalogAnswer { + let defaults = listed(session.objects(ObjectScope::DefaultSchema).await); + match scope { + CompletionScope::Objects { schema: None } => CatalogAnswer { + schemas: Some(listed(session.schemas().await)), + objects: Some(defaults), + columns: Vec::new(), + }, + CompletionScope::Objects { schema: Some(name) } => { + // `users.` typed before the FROM that names users. + let table = relation_named(&defaults, &name); + CatalogAnswer { + schemas: None, + objects: Some(listed(session.objects(ObjectScope::Schema(name)).await)), + columns: columns_of(&session, table.into_iter().collect()).await, + } + } + CompletionScope::Columns { tables } => CatalogAnswer { + schemas: None, + objects: None, + columns: columns_of(&session, resolved(&tables, &defaults)).await, + }, + CompletionScope::General { tables_in_statement } => CatalogAnswer { + schemas: None, + columns: columns_of(&session, resolved(&tables_in_statement, &defaults)).await, + objects: Some(defaults), + }, + } +} + +async fn columns_of(session: &Session, tables: Vec) -> Vec<(TableRef, Arc<[ColumnInfo]>)> { + let reads = tables.into_iter().map(|table| async move { + let columns = listed(session.columns(table.clone()).await); + (table, columns) + }); + futures::future::join_all(reads).await +} + +fn listed(read: Result, Arc>) -> Arc<[T]> { + read.unwrap_or_else(|error| { + tracing::debug!(%error, "completion left out a list it could not read"); + Arc::from([]) + }) +} + +fn resolved(tables: &[TableRef], defaults: &[ObjectInfo]) -> Vec { + tables.iter().map(|table| resolve(table, defaults)).collect() +} + +/// The catalog's own name for a table the statement names without a +/// schema, so the read is the one the table's other views share. +fn resolve(table: &TableRef, defaults: &[ObjectInfo]) -> TableRef { + if table.schema.is_some() { + return table.clone(); + } + relation_named(defaults, &table.name).unwrap_or_else(|| table.clone()) +} + +/// The default schema's relation called `name`, as written or, failing +/// that, ignoring case the way an unquoted name is read. +fn relation_named(defaults: &[ObjectInfo], name: &str) -> Option { + let relations = || defaults.iter().filter(|object| object.kind.is_browsable()); + let folded = name.to_lowercase(); + relations() + .find(|object| object.name == name) + .or_else(|| relations().find(|object| object.name.to_lowercase() == folded)) + .map(|object| TableRef::new(object.schema.clone(), object.name.clone())) +} + +#[cfg(test)] +mod tests { + use tablepro_core::meta::ObjectKind; + + use super::*; + + fn object(name: &str, kind: ObjectKind) -> ObjectInfo { + ObjectInfo { + schema: Some("public".to_owned()), + name: name.to_owned(), + kind, + } + } + + #[test] + fn an_unqualified_table_takes_the_catalogs_schema() { + let defaults = [object("users", ObjectKind::Table), object("Orders", ObjectKind::Table)]; + + assert_eq!( + resolve(&TableRef::unqualified("users"), &defaults), + TableRef::new(Some("public".to_owned()), "users") + ); + assert_eq!( + resolve(&TableRef::unqualified("orders"), &defaults), + TableRef::new(Some("public".to_owned()), "Orders") + ); + assert_eq!( + resolve(&TableRef::unqualified("missing"), &defaults), + TableRef::unqualified("missing") + ); + let qualified = TableRef::new(Some("sales".to_owned()), "users"); + assert_eq!(resolve(&qualified, &defaults), qualified); + } + + #[test] + fn a_function_is_not_a_relation() { + let defaults = [object("refresh", ObjectKind::Function)]; + + assert_eq!(relation_named(&defaults, "refresh"), None); + } +} diff --git a/linux/crates/app/src/ui/editor/completion/completion_scope.rs b/linux/crates/app/src/ui/editor/completion/completion_scope.rs new file mode 100644 index 0000000000..3719b35d66 --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/completion_scope.rs @@ -0,0 +1,533 @@ +use std::ops::Range; + +use tablepro_core::TableRef; +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptToken, ScriptTokenKind}; + +/// What the text before the cursor asks for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CompletionScope { + /// A table or view to name, from `schema` or the default one. + Objects { schema: Option }, + /// A column of one of these tables. + Columns { tables: Vec }, + /// Anything: keywords, objects, and the columns of the tables the + /// statement already names. + General { tables_in_statement: Vec }, +} + +/// The words after which a table name comes. +const RELATION_WORDS: [&str; 5] = ["FROM", "JOIN", "INTO", "UPDATE", "TABLE"]; + +/// Words that are never an alias, beyond the completed keywords. +const CLAUSE_WORDS: [&str; 12] = [ + "FETCH", + "FINAL", + "FOR", + "LATERAL", + "LIMIT", + "NATURAL", + "OFFSET", + "ONLY", + "PREWHERE", + "RETURNING", + "TABLESAMPLE", + "WINDOW", +]; + +/// What to complete at byte `cursor` of `text`. +/// +/// `None` inside a string, a comment, a quoted name or a dollar-quoted +/// body: what is typed there is not SQL the editor can read. +pub fn analyze(text: &str, cursor: usize, grammar: SqlGrammar, settings: LexicalSettings) -> Option { + let cursor = floor_char_boundary(text, cursor); + let plan = ScriptPlan::build(text, grammar, settings); + if plan + .tokens(cursor.saturating_sub(1)..cursor + 1) + .any(|token| encloses(&token, cursor)) + { + return None; + } + let Some(statement) = statement_around(&plan, cursor) else { + return Some(CompletionScope::General { + tables_in_statement: Vec::new(), + }); + }; + let lexemes: Vec> = plan + .tokens(statement) + .filter(|token| !token.kind.is_trivia()) + .filter_map(|token| Lexeme::new(text, token)) + .collect(); + let tables = TableMap::collect(&lexemes, grammar); + let mut before: Vec<&Lexeme<'_>> = lexemes.iter().take_while(|lexeme| lexeme.end <= cursor).collect(); + if before + .last() + .is_some_and(|last| last.kind == ScriptTokenKind::Word && last.end == cursor) + { + before.pop(); + } + Some(scope_after(&before, &tables, grammar)) +} + +fn scope_after(before: &[&Lexeme<'_>], tables: &TableMap, grammar: SqlGrammar) -> CompletionScope { + let general = || CompletionScope::General { + tables_in_statement: tables.in_order(), + }; + let Some((last, rest)) = before.split_last() else { + return general(); + }; + if last.is_punct(".") { + return after_dot(rest, tables).unwrap_or_else(general); + } + if RELATION_WORDS.iter().any(|word| last.is_word(word)) || (last.is_punct(",") && in_from_list(rest, grammar)) { + return CompletionScope::Objects { schema: None }; + } + general() +} + +/// `name.` names a table's columns when the statement knows the name, +/// and a schema's objects otherwise; `schema.table.` is always columns. +fn after_dot(rest: &[&Lexeme<'_>], tables: &TableMap) -> Option { + let (name, rest) = rest.split_last()?; + let name = name.identifier()?; + if let [.., schema, dot] = rest + && dot.is_punct(".") + && let Some(schema) = schema.identifier() + { + return Some(CompletionScope::Columns { + tables: vec![TableRef::new(Some(schema), name)], + }); + } + Some(match tables.lookup(&name) { + Some(table) => CompletionScope::Columns { + tables: vec![table.clone()], + }, + None => CompletionScope::Objects { schema: Some(name) }, + }) +} + +/// Whether a comma continues the table list of a FROM clause. +fn in_from_list(rest: &[&Lexeme<'_>], grammar: SqlGrammar) -> bool { + for lexeme in rest.iter().rev() { + if lexeme.is_word("FROM") { + return true; + } + let part_of_list = lexeme.is_punct(",") + || lexeme.is_punct(".") + || lexeme.is_word("AS") + || (lexeme.identifier().is_some() && !lexeme.is_keyword(grammar)); + if !part_of_list { + return false; + } + } + false +} + +/// The tables a statement names, under their aliases and their own +/// names. +#[derive(Debug, Default)] +struct TableMap { + entries: Vec<(String, TableRef)>, +} + +impl TableMap { + /// Every relation word starts a reference, including those inside a + /// subquery, so a cursor in the subquery sees its tables too. + fn collect(lexemes: &[Lexeme<'_>], grammar: SqlGrammar) -> Self { + let mut map = Self::default(); + for (index, lexeme) in lexemes.iter().enumerate() { + let Some(keyword) = RELATION_WORDS.iter().find(|word| lexeme.is_word(word)) else { + continue; + }; + let mut at = index + 1; + loop { + if lexemes.get(at).is_some_and(|next| next.is_word("ONLY")) { + at += 1; + } + let table = if lexemes.get(at).is_some_and(Lexeme::opens_group) { + at = after_group(lexemes, at); + None + } else { + let Some((table, next)) = table_ref_at(lexemes, at, grammar) else { + break; + }; + at = next; + // A set-returning call in FROM is not a table, and + // its arguments are no alias. + let called = lexemes.get(at).is_some_and(Lexeme::opens_group); + if called && matches!(*keyword, "FROM" | "JOIN") { + at = after_group(lexemes, at); + None + } else { + Some(table) + } + }; + let (alias, next) = alias_at(lexemes, at, grammar); + at = next; + if let Some(table) = table { + map.insert(table, alias); + } + if *keyword == "FROM" && lexemes.get(at).is_some_and(|next| next.is_punct(",")) { + at += 1; + continue; + } + break; + } + } + map + } + + fn insert(&mut self, table: TableRef, alias: Option) { + if let Some(alias) = alias { + self.entries.push((alias, table.clone())); + } + self.entries.push((table.name.clone(), table)); + } + + /// The table `name` stands for, matched as written first and then + /// ignoring case, the way an unquoted name is read. + fn lookup(&self, name: &str) -> Option<&TableRef> { + let folded = name.to_lowercase(); + self.entries + .iter() + .find(|(key, _)| key == name) + .or_else(|| self.entries.iter().find(|(key, _)| key.to_lowercase() == folded)) + .map(|(_, table)| table) + } + + fn in_order(&self) -> Vec { + let mut tables: Vec = Vec::new(); + for (_, table) in &self.entries { + if !tables.contains(table) { + tables.push(table.clone()); + } + } + tables + } +} + +/// `name`, `schema.name` or `catalog.schema.name` at `index`, and the +/// index after it. +fn table_ref_at(lexemes: &[Lexeme<'_>], index: usize, grammar: SqlGrammar) -> Option<(TableRef, usize)> { + let mut parts = Vec::new(); + let mut at = index; + loop { + let lexeme = lexemes.get(at)?; + if lexeme.is_keyword(grammar) { + break; + } + parts.push(lexeme.identifier()?); + at += 1; + match lexemes.get(at) { + Some(dot) if dot.is_punct(".") => at += 1, + _ => break, + } + } + let name = parts.pop()?; + Some((TableRef::new(parts.pop(), name), at)) +} + +/// The index after the parenthesised group that opens at `index`. +fn after_group(lexemes: &[Lexeme<'_>], index: usize) -> usize { + let mut depth = 0_usize; + for (at, lexeme) in lexemes.iter().enumerate().skip(index) { + match lexeme.kind { + ScriptTokenKind::OpenParen => depth += 1, + ScriptTokenKind::CloseParen => { + depth = depth.saturating_sub(1); + if depth == 0 { + return at + 1; + } + } + _ => {} + } + } + lexemes.len() +} + +fn alias_at(lexemes: &[Lexeme<'_>], index: usize, grammar: SqlGrammar) -> (Option, usize) { + let Some(lexeme) = lexemes.get(index) else { + return (None, index); + }; + if lexeme.is_word("AS") { + let alias = lexemes.get(index + 1).and_then(Lexeme::identifier); + return (alias, index + 2); + } + match lexeme.identifier() { + Some(alias) if !lexeme.is_keyword(grammar) => (Some(alias), index + 1), + _ => (None, index), + } +} + +/// The statement the cursor is in or just after, unless a terminator +/// already ended it. +fn statement_around(plan: &ScriptPlan, cursor: usize) -> Option> { + let statement = plan.statement_at(cursor)?; + if cursor <= statement.range.end { + return Some(statement.range.clone()); + } + let ended = plan + .tokens(statement.range.end..cursor) + .any(|token| matches!(token.kind, ScriptTokenKind::Semicolon | ScriptTokenKind::Directive)); + (!ended).then_some(statement.range.start..cursor) +} + +/// Whether the cursor sits inside a token whose text is not SQL. A +/// line comment and an unclosed construct run to their end, so the end +/// counts as inside; a closed quote ends before its last character. +fn encloses(token: &ScriptToken, cursor: usize) -> bool { + let inside = token.range.start < cursor && cursor < token.range.end; + let at_open_end = token.range.start < cursor && cursor == token.range.end; + match token.kind { + ScriptTokenKind::LineComment | ScriptTokenKind::Unterminated | ScriptTokenKind::Directive => { + inside || at_open_end + } + ScriptTokenKind::StringLiteral + | ScriptTokenKind::QuotedIdentifier + | ScriptTokenKind::DollarQuoted + | ScriptTokenKind::BlockComment => inside, + _ => false, + } +} + +fn floor_char_boundary(text: &str, byte: usize) -> usize { + let mut byte = byte.min(text.len()); + while !text.is_char_boundary(byte) { + byte -= 1; + } + byte +} + +#[derive(Debug)] +struct Lexeme<'a> { + kind: ScriptTokenKind, + text: &'a str, + end: usize, +} + +impl<'a> Lexeme<'a> { + fn new(source: &'a str, token: ScriptToken) -> Option { + Some(Self { + kind: token.kind, + text: source.get(token.range.clone())?, + end: token.range.end, + }) + } + + fn is_word(&self, word: &str) -> bool { + self.kind == ScriptTokenKind::Word && self.text.eq_ignore_ascii_case(word) + } + + fn is_punct(&self, punct: &str) -> bool { + self.kind == ScriptTokenKind::Punct && self.text == punct + } + + fn opens_group(&self) -> bool { + self.kind == ScriptTokenKind::OpenParen + } + + fn is_keyword(&self, grammar: SqlGrammar) -> bool { + self.kind == ScriptTokenKind::Word + && (grammar.keywords().any(|keyword| self.is_word(keyword)) + || CLAUSE_WORDS.iter().any(|keyword| self.is_word(keyword))) + } + + /// The name a word or a quoted identifier spells. + fn identifier(&self) -> Option { + match self.kind { + ScriptTokenKind::Word => Some(self.text.to_owned()), + ScriptTokenKind::QuotedIdentifier => Some(unquote(self.text)), + _ => None, + } + } +} + +fn unquote(text: &str) -> String { + let close = match text.chars().next() { + Some('"') => '"', + Some('`') => '`', + Some('[') => ']', + _ => return text.to_owned(), + }; + let inner = text + .get(1..text.len().saturating_sub(close.len_utf8())) + .unwrap_or_default(); + inner.replace(&format!("{close}{close}"), &close.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The scope at the `|` in `marked`. + fn scope_in(grammar: SqlGrammar, marked: &str) -> Option { + let cursor = marked.find('|').expect("a cursor mark"); + let text = marked.replacen('|', "", 1); + analyze(&text, cursor, grammar, LexicalSettings::default_for(grammar)) + } + + fn scope(marked: &str) -> Option { + scope_in(SqlGrammar::PostgreSql, marked) + } + + fn table(schema: Option<&str>, name: &str) -> TableRef { + TableRef::new(schema.map(str::to_owned), name) + } + + fn columns(tables: Vec) -> Option { + Some(CompletionScope::Columns { tables }) + } + + fn general(tables: Vec) -> Option { + Some(CompletionScope::General { + tables_in_statement: tables, + }) + } + + const OBJECTS: Option = Some(CompletionScope::Objects { schema: None }); + + #[test] + fn after_from_suggests_objects() { + assert_eq!(scope("SELECT * FROM |"), OBJECTS); + assert_eq!(scope("SELECT * FROM us|"), OBJECTS); + assert_eq!(scope("select * from users u join |"), OBJECTS); + assert_eq!(scope("SELECT * FROM users u, |"), OBJECTS); + assert_eq!(scope("INSERT INTO |"), OBJECTS); + assert_eq!(scope("UPDATE |"), OBJECTS); + } + + #[test] + fn alias_dot_suggests_that_tables_columns() { + assert_eq!(scope("SELECT u.| FROM users u"), columns(vec![table(None, "users")])); + assert_eq!( + scope("SELECT u.na| FROM public.users AS u"), + columns(vec![table(Some("public"), "users")]) + ); + assert_eq!(scope("SELECT users.| FROM users"), columns(vec![table(None, "users")])); + assert_eq!( + scope("SELECT public.users.| FROM x"), + columns(vec![table(Some("public"), "users")]) + ); + } + + #[test] + fn schema_dot_suggests_schema_objects() { + assert_eq!( + scope("SELECT * FROM sales.|"), + Some(CompletionScope::Objects { + schema: Some("sales".to_owned()) + }) + ); + assert_eq!( + scope("SELECT * FROM sales.ord|"), + Some(CompletionScope::Objects { + schema: Some("sales".to_owned()) + }) + ); + } + + #[test] + fn quoted_alias_is_unquoted() { + assert_eq!( + scope("SELECT \"Đơn\".| FROM \"Order \"\"Items\"\"\" AS \"Đơn\""), + columns(vec![table(None, "Order \"Items\"")]) + ); + assert_eq!( + scope_in(SqlGrammar::MySql, "SELECT o.| FROM `shop`.`order items` o"), + columns(vec![table(Some("shop"), "order items")]) + ); + assert_eq!( + scope_in(SqlGrammar::MsSql, "SELECT o.| FROM [dbo].[order]]s] o"), + columns(vec![table(Some("dbo"), "order]s")]) + ); + } + + #[test] + fn join_aliases_collected() { + let text = "SELECT | FROM users u JOIN sales.orders o ON o.user_id = u.id LEFT JOIN items ON true"; + assert_eq!( + scope(text), + general(vec![ + table(None, "users"), + table(Some("sales"), "orders"), + table(None, "items") + ]) + ); + assert_eq!( + scope("SELECT * FROM users u JOIN orders o ON o.| = u.id"), + columns(vec![table(None, "orders")]) + ); + assert_eq!( + scope("SELECT * FROM users U WHERE u.|"), + columns(vec![table(None, "users")]), + "an unquoted alias is matched without case" + ); + } + + #[test] + fn dollar_quoted_text_is_not_analyzed() { + assert_eq!(scope("SELECT $$ FROM $$ |"), general(Vec::new())); + assert_eq!(scope("SELECT $$ FROM |$$"), None); + assert_eq!(scope("SELECT $$ FROM |"), None); + assert_eq!( + scope("CREATE FUNCTION f() RETURNS int AS $body$ SELECT * FROM | $body$ LANGUAGE sql"), + None + ); + } + + #[test] + fn strings_and_comments_are_not_completed() { + assert_eq!(scope("SELECT 'FROM |'"), None); + assert_eq!(scope("SELECT 1 -- FROM |"), None); + assert_eq!(scope("SELECT /* FROM | */ 1"), None); + assert_eq!(scope("SELECT \"us|"), None); + assert_eq!(scope("SELECT 'a' |"), general(Vec::new())); + } + + #[test] + fn a_new_statement_starts_after_the_terminator() { + assert_eq!(scope("SELECT * FROM users;\n|"), general(Vec::new())); + assert_eq!(scope("SELECT * FROM users;\nsel|"), general(Vec::new())); + assert_eq!( + scope("SELECT * FROM users u WHERE |;\nSELECT 2"), + general(vec![table(None, "users")]) + ); + } + + #[test] + fn a_comma_outside_from_is_not_a_table_list() { + assert_eq!(scope("SELECT a, | FROM users"), general(vec![table(None, "users")])); + assert_eq!( + scope("SELECT * FROM users WHERE a IN (1, |)"), + general(vec![table(None, "users")]) + ); + } + + #[test] + fn a_call_or_subquery_in_from_is_not_a_table() { + assert_eq!( + scope("SELECT | FROM generate_series(1, 3) g, users"), + general(vec![table(None, "users")]) + ); + assert_eq!( + scope("SELECT | FROM (SELECT * FROM orders) o, users"), + general(vec![table(None, "users"), table(None, "orders")]) + ); + assert_eq!(scope("INSERT INTO users (|"), general(vec![table(None, "users")])); + } + + #[test] + fn a_multibyte_cursor_does_not_split_a_character() { + let text = "SELECT * FROM đơn"; + let inside_a_character = text.len() - 2; + + let scope = analyze( + text, + inside_a_character, + SqlGrammar::PostgreSql, + LexicalSettings::default_for(SqlGrammar::PostgreSql), + ); + + assert_eq!(scope, OBJECTS); + } +} diff --git a/linux/crates/app/src/ui/editor/completion/fuzzy.rs b/linux/crates/app/src/ui/editor/completion/fuzzy.rs new file mode 100644 index 0000000000..1fd06c19ea --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/fuzzy.rs @@ -0,0 +1,151 @@ +//! GtkSourceView's fuzzy ranking, over characters rather than bytes. +//! +//! `gtk_source_completion_fuzzy_match` looks each needle character up +//! with `strchr`, so a needle with a character outside ASCII never +//! matches: "đơn" does not find "đơn_hàng". This keeps its scoring and +//! compares whole characters. + +use std::ops::Range; + +use gtk4::glib; +use gtk4::pango; + +/// The needle in the form the matcher compares against. +pub fn casefold(needle: &str) -> String { + glib::casefold(needle).into() +} + +/// How far `haystack` is from `casefold_needle`, lower being closer, or +/// `None` when it does not match. +pub fn fuzzy_match(haystack: &str, casefold_needle: &str) -> Option { + matched(haystack, casefold_needle).map(|found| found.score) +} + +/// Bold for the characters of `haystack` the needle matched. +pub fn highlight(haystack: &str, casefold_needle: &str) -> Option { + let found = matched(haystack, casefold_needle)?; + if found.runs.is_empty() { + return None; + } + let attrs = pango::AttrList::new(); + for run in found.runs { + let mut bold = pango::AttrInt::new_weight(pango::Weight::Bold); + bold.set_start_index(u32::try_from(run.start).unwrap_or(u32::MAX)); + bold.set_end_index(u32::try_from(run.end).unwrap_or(u32::MAX)); + attrs.insert(bold); + } + Some(attrs) +} + +struct Match { + score: u32, + /// Byte ranges of the matched characters, adjacent ones joined. + runs: Vec>, +} + +/// Each needle character at its first place after the last one. A +/// character skipped costs two, a match only by case one, and every +/// character left at the end one, so a prefix ranks first. +fn matched(haystack: &str, casefold_needle: &str) -> Option { + if haystack.is_empty() { + return None; + } + let mut chars = haystack.char_indices(); + let mut score: u32 = 0; + let mut runs: Vec> = Vec::new(); + for wanted in casefold_needle.chars() { + let mut skipped: u32 = 0; + let (at, found) = loop { + let (at, found) = chars.next()?; + if same_letter(found, wanted) { + break (at, found); + } + skipped += 1; + }; + score = score.saturating_add(skipped.saturating_mul(2)); + if found != wanted { + score = score.saturating_add(1); + } + let end = at + found.len_utf8(); + match runs.last_mut() { + Some(run) if run.end == at => run.end = end, + _ => runs.push(at..end), + } + } + let rest = u32::try_from(chars.count()).unwrap_or(u32::MAX); + Some(Match { + score: score.saturating_add(rest), + runs, + }) +} + +fn same_letter(found: char, wanted: char) -> bool { + found == wanted || found.to_lowercase().eq(wanted.to_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fuzzy_match_ranks_prefix_higher() { + let needle = casefold("us"); + + let prefix = fuzzy_match("users", &needle).expect("a match"); + let scattered = fuzzy_match("product_status", &needle).expect("a match"); + + assert!(prefix < scattered, "{prefix} is not closer than {scattered}"); + assert_eq!(fuzzy_match("orders", &needle), None); + assert_eq!(fuzzy_match("su", &needle), None, "the order of the letters was ignored"); + } + + #[test] + fn an_exact_case_match_ranks_above_one_by_case() { + let needle = casefold("id"); + + assert!(fuzzy_match("id", &needle) < fuzzy_match("ID", &needle)); + } + + #[test] + fn non_ascii_needle_is_casefolded() { + let needle = casefold("ĐƠN"); + + assert_eq!(needle, "đơn"); + assert!(fuzzy_match("đơn_hàng", &needle).is_some()); + assert!(fuzzy_match("ĐƠN_HÀNG", &needle).is_some()); + assert_eq!(fuzzy_match("don_hang", &needle), None); + } + + #[test] + fn an_empty_needle_matches_everything() { + assert!(fuzzy_match("users", "").is_some()); + assert_eq!(fuzzy_match("", ""), None); + } + + #[test] + fn highlight_returns_attrs() { + let attrs = highlight("users", &casefold("US")).expect("an attribute list"); + + let runs: Vec<(u32, u32)> = attrs + .attributes() + .iter() + .map(|attr| (attr.start_index(), attr.end_index())) + .collect(); + assert_eq!(runs, [(0, 2)]); + } + + #[test] + fn highlight_marks_whole_multibyte_characters() { + let attrs = highlight("bảng_đơn", &casefold("đn")).expect("an attribute list"); + + let runs: Vec<(u32, u32)> = attrs + .attributes() + .iter() + .map(|attr| (attr.start_index(), attr.end_index())) + .collect(); + let text = "bảng_đơn"; + let d = u32::try_from(text.find('đ').expect("đ")).expect("a short text"); + let n = u32::try_from(text.rfind('n').expect("n")).expect("a short text"); + assert_eq!(runs, [(d, d + 2), (n, n + 1)]); + } +} diff --git a/linux/crates/app/src/ui/editor/completion/mod.rs b/linux/crates/app/src/ui/editor/completion/mod.rs new file mode 100644 index 0000000000..d0aa9e02a2 --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/mod.rs @@ -0,0 +1,14 @@ +//! Completion in the SQL editor from the session's schema catalog. + +mod catalog_read; +mod completion_scope; +mod fuzzy; +mod proposal_kind; +mod sql_completion_provider; +mod sql_completion_provider_imp; +mod sql_proposal; +mod sql_proposal_imp; +#[cfg(test)] +mod tests; + +pub use sql_completion_provider::SqlCompletionProvider; diff --git a/linux/crates/app/src/ui/editor/completion/proposal_kind.rs b/linux/crates/app/src/ui/editor/completion/proposal_kind.rs new file mode 100644 index 0000000000..cbc4b2b4b3 --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/proposal_kind.rs @@ -0,0 +1,88 @@ +use tablepro_core::TableRef; +use tablepro_core::meta::ObjectKind; + +/// What a proposal names. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProposalKind { + Keyword, + Schema, + Table, + View, + MaterializedView, + Column { data_type: String, table: TableRef }, + Function, +} + +impl ProposalKind { + /// The proposal for a catalog object, or `None` for one no + /// statement names by itself, such as a sequence, or a kind added + /// after this list. + pub fn for_object(kind: ObjectKind) -> Option { + match kind { + ObjectKind::Table | ObjectKind::PartitionedTable | ObjectKind::ForeignTable | ObjectKind::Dictionary => { + Some(Self::Table) + } + ObjectKind::View => Some(Self::View), + ObjectKind::MaterializedView => Some(Self::MaterializedView), + ObjectKind::Function | ObjectKind::Procedure => Some(Self::Function), + _ => None, + } + } + + /// A column shows its type instead of an icon, and a keyword is + /// plain text. + pub fn icon_name(&self) -> Option<&'static str> { + match self { + Self::Schema => Some(crate::ui::icons::FOLDER), + Self::Table => Some(crate::ui::icons::VIEW_LIST), + Self::View | Self::MaterializedView => Some(crate::ui::icons::VIEW_REVEAL), + Self::Function => Some(crate::ui::icons::SYSTEM_RUN), + Self::Keyword | Self::Column { .. } => None, + } + } + + /// The order of two proposals that match equally well: what the + /// statement already names comes before what it might. + pub fn rank(&self) -> u8 { + match self { + Self::Column { .. } => 0, + Self::Table | Self::View | Self::MaterializedView => 1, + Self::Schema => 2, + Self::Function => 3, + Self::Keyword => 4, + } + } + + pub fn is_keyword(&self) -> bool { + matches!(self, Self::Keyword) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_sequence_is_not_proposed() { + assert_eq!(ProposalKind::for_object(ObjectKind::Sequence), None); + assert_eq!( + ProposalKind::for_object(ObjectKind::PartitionedTable), + Some(ProposalKind::Table) + ); + assert_eq!( + ProposalKind::for_object(ObjectKind::Procedure), + Some(ProposalKind::Function) + ); + } + + #[test] + fn columns_come_before_tables_and_keywords_last() { + let column = ProposalKind::Column { + data_type: "integer".to_owned(), + table: TableRef::unqualified("users"), + }; + + assert!(column.rank() < ProposalKind::Table.rank()); + assert!(ProposalKind::Table.rank() < ProposalKind::Keyword.rank()); + } +} diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs new file mode 100644 index 0000000000..304cb97cc9 --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs @@ -0,0 +1,240 @@ +use std::sync::Arc; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use sourceview5::CompletionColumn; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::ObjectInfo; +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::script::{LexicalSettings, text_offsets}; +use tablepro_session::Session; +use tablepro_session::runtime::Tasks; + +use super::catalog_read::{self, CatalogAnswer}; +use super::completion_scope::{self, CompletionScope}; +use super::fuzzy; +use super::proposal_kind::ProposalKind; +use super::sql_completion_provider_imp as imp; +use super::sql_proposal::SqlProposal; +use crate::services::active_session::ActiveSession; + +glib::wrapper! { + /// Completes keywords, and the tables, views and columns of the + /// session's catalog, for what the statement at the cursor asks. + pub struct SqlCompletionProvider(ObjectSubclass) + @implements sourceview5::CompletionProvider; +} + +pub struct ProviderDeps { + session: ActiveSession, + tasks: Tasks, +} + +impl SqlCompletionProvider { + pub fn new(session: ActiveSession, tasks: Tasks) -> Self { + let provider: Self = glib::Object::new(); + let _ = provider.imp().deps.set(ProviderDeps { session, tasks }); + provider + } + + /// The proposals for `word`, which starts at character `word_start` + /// of `buffer`. + pub async fn proposals(&self, buffer: >k4::TextBuffer, word_start: i32, word: &str) -> gio::ListModel { + self.set_needle(word); + let store = gio::ListStore::new::(); + let model = self.sorted(&store); + let Some(request) = self.request(buffer, word_start) else { + return model; + }; + let answer = match (&request.session, self.imp().deps.get()) { + (Some(session), Some(deps)) => deps + .tasks + .spawn_task(catalog_read::read(session.clone(), request.scope.clone())) + .await + .unwrap_or_else(|failure| { + tracing::warn!(%failure, "reading the catalog for completion failed"); + CatalogAnswer::default() + }), + _ => CatalogAnswer::default(), + }; + store.extend_from_slice(&request.proposals(&answer)); + model + } + + /// Narrow the list to the word now typed. + pub fn refilter(&self, word: &str, model: &gio::ListModel) { + self.set_needle(word); + let Some(sorted) = model.downcast_ref::() else { + return; + }; + if let Some(filter) = sorted + .model() + .and_downcast::() + .and_then(|filtered| filtered.filter()) + { + filter.changed(gtk4::FilterChange::Different); + } + if let Some(sorter) = sorted.sorter() { + sorter.changed(gtk4::SorterChange::Different); + } + } + + pub(super) fn empty(&self) -> gio::ListModel { + self.sorted(&gio::ListStore::new::()) + } + + fn set_needle(&self, word: &str) { + self.imp().needle.replace(fuzzy::casefold(word)); + } + + /// The store filtered by the needle and sorted closest match first. + fn sorted(&self, store: &gio::ListStore) -> gio::ListModel { + let needle = self.imp().needle.clone(); + let filter = gtk4::CustomFilter::new(move |item| { + let Some(proposal) = item.downcast_ref::() else { + return false; + }; + let needle = needle.borrow(); + // With nothing typed the matcher ranks by length alone, + // which would put every short keyword above the columns. + let priority = if needle.is_empty() { + Some(0) + } else { + fuzzy::fuzzy_match(proposal.label(), &needle) + }; + match priority { + Some(priority) => { + proposal.set_priority(priority); + true + } + None => false, + } + }); + let sorter = + gtk4::CustomSorter::new( + |a, b| match (a.downcast_ref::(), b.downcast_ref::()) { + (Some(a), Some(b)) => a.compare(b).into(), + _ => gtk4::Ordering::Equal, + }, + ); + let filtered = gtk4::FilterListModel::new(Some(store.clone()), Some(filter)); + gtk4::SortListModel::new(Some(filtered), Some(sorter)).upcast() + } + + fn request(&self, buffer: >k4::TextBuffer, word_start: i32) -> Option { + let session = self.imp().deps.get().and_then(|deps| deps.session.get()); + let dialect = session.as_ref().and_then(|session| session.current_dialect()); + // With no connection the keywords still complete, and + // PostgreSQL's reading of the text is the strictest. + let grammar = dialect + .as_ref() + .map_or(SqlGrammar::PostgreSql, |dialect| dialect.grammar()); + let (start, end) = buffer.bounds(); + let text = buffer.text(&start, &end, false); + let cursor = text_offsets::char_to_byte(&text, usize::try_from(word_start).unwrap_or_default()); + let scope = completion_scope::analyze(&text, cursor, grammar, LexicalSettings::default_for(grammar))?; + Some(Request { + scope, + grammar, + session, + dialect, + }) + } +} + +/// One completion's question, read on the GTK thread. +struct Request { + scope: CompletionScope, + grammar: SqlGrammar, + session: Option>, + dialect: Option>, +} + +impl Request { + fn proposals(&self, answer: &CatalogAnswer) -> Vec { + let mut proposals = Vec::new(); + let mut columns: Vec<&str> = Vec::new(); + for (table, list) in &answer.columns { + for column in list.iter() { + // The same name in two joined tables is one thing to type. + if columns.contains(&column.name.as_str()) { + continue; + } + columns.push(&column.name); + let kind = ProposalKind::Column { + data_type: column.column_type.name.clone(), + table: table.clone(), + }; + proposals.push(SqlProposal::new(kind, &column.name, &self.identifier(&column.name))); + } + } + if matches!(self.scope, CompletionScope::Objects { schema: None }) { + for schema in answer.schemas.iter().flat_map(|schemas| schemas.iter()) { + if !schema.is_system { + proposals.push(SqlProposal::new( + ProposalKind::Schema, + &schema.name, + &self.identifier(&schema.name), + )); + } + } + } + let general = matches!(self.scope, CompletionScope::General { .. }); + for object in answer.objects.iter().flat_map(|objects| objects.iter()) { + if let Some(proposal) = self.object(object, general) { + proposals.push(proposal); + } + } + if general { + proposals.extend(self.grammar.keywords().map(SqlProposal::keyword)); + } + proposals + } + + /// A relation anywhere a table goes; a function only where an + /// expression can go. + fn object(&self, object: &ObjectInfo, general: bool) -> Option { + let kind = ProposalKind::for_object(object.kind)?; + if kind == ProposalKind::Function && !general { + return None; + } + Some(SqlProposal::new(kind, &object.name, &self.identifier(&object.name))) + } + + /// `name` as the connection's dialect writes it, quoted only when + /// it would read as something else bare. + fn identifier(&self, name: &str) -> String { + match &self.dialect { + Some(dialect) if !self.grammar.is_bare_identifier(name) => dialect.quote_identifier(name), + _ => name.to_owned(), + } + } +} + +/// What a column of the list shows for `proposal`, beside its icon and +/// name. +pub fn column_text(proposal: &SqlProposal, column: CompletionColumn) -> Option { + match (proposal.kind(), column) { + (ProposalKind::Column { data_type, .. }, CompletionColumn::After) => Some(data_type.clone()), + (ProposalKind::Column { table, .. }, CompletionColumn::Comment) => Some(match &table.schema { + Some(schema) => format!("{schema}.{}", table.name), + None => table.name.clone(), + }), + _ => None, + } +} + +/// Replace the word between `begin` and `end` with `text`, as one step +/// Ctrl+Z takes back. A cursor inside a word replaces all of it. +pub fn replace_word(buffer: >k4::TextBuffer, mut begin: gtk4::TextIter, mut end: gtk4::TextIter, text: &str) { + while end.char().is_alphanumeric() || end.char() == '_' { + if !end.forward_char() { + break; + } + } + buffer.begin_user_action(); + buffer.delete(&mut begin, &mut end); + buffer.insert(&mut begin, text); + buffer.end_user_action(); +} diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs new file mode 100644 index 0000000000..0c521f4dfa --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs @@ -0,0 +1,98 @@ +use std::cell::{OnceCell, RefCell}; +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use sourceview5::subclass::prelude::*; +use sourceview5::{CompletionCell, CompletionColumn, CompletionContext, CompletionProposal}; + +use super::fuzzy; +use super::sql_completion_provider::{ProviderDeps, column_text, replace_word}; +use super::sql_proposal::SqlProposal; + +/// Ahead of the document's own words. +const PRIORITY: i32 = 100; + +#[derive(Default)] +pub struct SqlCompletionProvider { + pub deps: OnceCell, + /// The casefolded word typed so far, which the list filters on. + pub needle: Rc>, +} + +#[glib::object_subclass] +impl ObjectSubclass for SqlCompletionProvider { + const NAME: &'static str = "TableProSqlCompletionProvider"; + type Type = super::sql_completion_provider::SqlCompletionProvider; + type Interfaces = (sourceview5::CompletionProvider,); +} + +impl ObjectImpl for SqlCompletionProvider {} + +impl CompletionProviderImpl for SqlCompletionProvider { + fn title(&self) -> Option { + Some(crate::i18n::pgettext("completion group", "Database").into()) + } + + fn priority(&self, _context: &CompletionContext) -> i32 { + PRIORITY + } + + fn is_trigger(&self, _iter: >k4::TextIter, c: char) -> bool { + c == '.' + } + + fn populate_future( + &self, + context: &CompletionContext, + ) -> Pin>>> { + let provider = self.obj().clone(); + let context = context.clone(); + Box::pin(async move { + let (Some(buffer), Some((begin, _))) = (context.buffer(), context.bounds()) else { + return Ok(provider.empty()); + }; + let word = context.word(); + Ok(provider.proposals(buffer.upcast_ref(), begin.offset(), &word).await) + }) + } + + fn refilter(&self, context: &CompletionContext, model: &gio::ListModel) { + self.obj().refilter(&context.word(), model); + } + + fn display(&self, context: &CompletionContext, proposal: &CompletionProposal, cell: &CompletionCell) { + let Some(proposal) = proposal.downcast_ref::() else { + return; + }; + let word = context.word(); + match cell.column() { + CompletionColumn::Icon => match proposal.kind().icon_name() { + Some(icon) => cell.set_icon_name(icon), + None => cell.set_text(None), + }, + CompletionColumn::TypedText => { + let text = proposal.shown_text(&word); + match fuzzy::highlight(&text, &fuzzy::casefold(&word)) { + Some(attrs) => cell.set_text_with_attributes(&text, &attrs), + None => cell.set_text(Some(&text)), + } + } + column => cell.set_text(column_text(proposal, column).as_deref()), + } + } + + fn activate(&self, context: &CompletionContext, proposal: &CompletionProposal) { + let (Some(proposal), Some(buffer), Some((begin, end))) = ( + proposal.downcast_ref::(), + context.buffer(), + context.bounds(), + ) else { + return; + }; + replace_word(buffer.upcast_ref(), begin, end, &proposal.insert_text(&context.word())); + } +} diff --git a/linux/crates/app/src/ui/editor/completion/sql_proposal.rs b/linux/crates/app/src/ui/editor/completion/sql_proposal.rs new file mode 100644 index 0000000000..2480e4c85b --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/sql_proposal.rs @@ -0,0 +1,111 @@ +use std::cmp::Ordering; + +use gtk4::glib; +use gtk4::subclass::prelude::*; + +use super::proposal_kind::ProposalKind; +use super::sql_proposal_imp as imp; + +glib::wrapper! { + /// One entry of the editor's completion list. + pub struct SqlProposal(ObjectSubclass) + @implements sourceview5::CompletionProposal; +} + +impl SqlProposal { + pub fn new(kind: ProposalKind, label: &str, insert_text: &str) -> Self { + let proposal: Self = glib::Object::new(); + let imp = proposal.imp(); + let _ = imp.kind.set(kind); + let _ = imp.label.set(label.to_owned()); + let _ = imp.insert_text.set(insert_text.to_owned()); + proposal + } + + pub fn keyword(keyword: &str) -> Self { + Self::new(ProposalKind::Keyword, keyword, keyword) + } + + pub fn kind(&self) -> &ProposalKind { + static KEYWORD: ProposalKind = ProposalKind::Keyword; + self.imp().kind.get().unwrap_or(&KEYWORD) + } + + pub fn label(&self) -> &str { + self.imp().label.get().map_or("", String::as_str) + } + + /// The text the list shows while `word` is typed: a keyword follows + /// the case the user writes keywords in. + pub fn shown_text(&self, word: &str) -> String { + self.cased(self.label(), word) + } + + /// The text that replaces `word` when the proposal is chosen. + pub fn insert_text(&self, word: &str) -> String { + let text = self.imp().insert_text.get().map_or("", String::as_str); + self.cased(text, word) + } + + pub fn priority(&self) -> u32 { + self.imp().priority.get() + } + + pub fn set_priority(&self, priority: u32) { + self.imp().priority.set(priority); + } + + /// The list order: the closest match first, then what the + /// statement names before keywords. Ties keep the catalog's order, + /// which lists a table's columns as the table defines them. + pub fn compare(&self, other: &Self) -> Ordering { + self.priority() + .cmp(&other.priority()) + .then_with(|| self.kind().rank().cmp(&other.kind().rank())) + } + + fn cased(&self, text: &str, word: &str) -> String { + let lowercase = word.chars().any(char::is_lowercase) && !word.chars().any(char::is_uppercase); + if self.kind().is_keyword() && lowercase { + text.to_lowercase() + } else { + text.to_owned() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_keyword_follows_the_case_typed() { + let select = SqlProposal::keyword("SELECT"); + + assert_eq!(select.insert_text("sel"), "select"); + assert_eq!(select.insert_text("SEL"), "SELECT"); + assert_eq!(select.insert_text("Sel"), "SELECT"); + assert_eq!(select.insert_text(""), "SELECT"); + assert_eq!(select.shown_text("se"), "select"); + } + + #[test] + fn an_identifier_keeps_its_case() { + let table = SqlProposal::new(ProposalKind::Table, "Users", "\"Users\""); + + assert_eq!(table.insert_text("us"), "\"Users\""); + assert_eq!(table.shown_text("us"), "Users"); + } + + #[test] + fn closer_matches_sort_first_then_by_kind() { + let keyword = SqlProposal::keyword("USING"); + let table = SqlProposal::new(ProposalKind::Table, "users", "users"); + keyword.set_priority(3); + table.set_priority(3); + + assert_eq!(table.compare(&keyword), Ordering::Less); + keyword.set_priority(1); + assert_eq!(table.compare(&keyword), Ordering::Greater); + } +} diff --git a/linux/crates/app/src/ui/editor/completion/sql_proposal_imp.rs b/linux/crates/app/src/ui/editor/completion/sql_proposal_imp.rs new file mode 100644 index 0000000000..dcef181459 --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/sql_proposal_imp.rs @@ -0,0 +1,27 @@ +use std::cell::{Cell, OnceCell}; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use sourceview5::subclass::prelude::*; + +use super::proposal_kind::ProposalKind; + +#[derive(Default)] +pub struct SqlProposal { + pub kind: OnceCell, + pub label: OnceCell, + pub insert_text: OnceCell, + /// The last fuzzy score, written by the filter for the sorter. + pub priority: Cell, +} + +#[glib::object_subclass] +impl ObjectSubclass for SqlProposal { + const NAME: &'static str = "TableProSqlProposal"; + type Type = super::sql_proposal::SqlProposal; + type Interfaces = (sourceview5::CompletionProposal,); +} + +impl ObjectImpl for SqlProposal {} + +impl CompletionProposalImpl for SqlProposal {} diff --git a/linux/crates/app/src/ui/editor/completion/tests.rs b/linux/crates/app/src/ui/editor/completion/tests.rs new file mode 100644 index 0000000000..2d4826ce2d --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/tests.rs @@ -0,0 +1,384 @@ +use std::rc::Rc; +use std::time::Duration; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use sourceview5::CompletionColumn; +use sourceview5::prelude::*; +use tablepro_core::meta::{ObjectInfo, ObjectKind}; +use tablepro_core::{ColumnInfo, TableRef}; +use tablepro_test_support::{ConnectionScript, FakeReply}; + +use super::proposal_kind::ProposalKind; +use super::sql_completion_provider::{SqlCompletionProvider, column_text, replace_word}; +use super::sql_proposal::SqlProposal; +use crate::services::active_session::ActiveSession; +use crate::test_support::{FakeSession, MemorySettings, descendants, finish_on_main, test_runtime, wait_until}; +use crate::ui::editor::{SqlEditor, SqlEditorInit}; + +const WAIT: Duration = Duration::from_secs(10); + +struct Harness { + provider: SqlCompletionProvider, + _session: Option, + _runtime: crate::runtime::AppRuntime, +} + +fn object(name: &str, kind: ObjectKind) -> ObjectInfo { + ObjectInfo { + schema: Some("public".to_owned()), + name: name.to_owned(), + kind, + } +} + +fn column(name: &str, data_type: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: tablepro_core::column::ColumnType::untyped_text(data_type), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: tablepro_core::column::ColumnDefault::None, + comment: None, + } +} + +/// A provider on a session whose default schema holds `objects`. +fn connected(script: ConnectionScript, objects: Vec) -> Harness { + let runtime = test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + fake.session.catalog().commit(Vec::new(), objects); + let active = ActiveSession::new(); + active.replace(fake.session.clone()); + Harness { + provider: SqlCompletionProvider::new(active, runtime.tasks()), + _session: Some(fake), + _runtime: runtime, + } +} + +fn unconnected() -> Harness { + let runtime = test_runtime(); + Harness { + provider: SqlCompletionProvider::new(ActiveSession::new(), runtime.tasks()), + _session: None, + _runtime: runtime, + } +} + +/// The word before the `|` in `marked`, and where it starts. +fn word_before(marked: &str) -> (String, i32, String) { + let cursor = marked.find('|').expect("a cursor mark"); + let text = marked.replacen('|', "", 1); + let word: String = marked[..cursor] + .chars() + .rev() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect::>() + .into_iter() + .rev() + .collect(); + let start = marked[..cursor].chars().count() - word.chars().count(); + (text, i32::try_from(start).expect("a short text"), word) +} + +fn proposals(harness: &Harness, marked: &str) -> Vec { + let (text, start, word) = word_before(marked); + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text(&text); + let provider = harness.provider.clone(); + let model = + finish_on_main(WAIT, async move { provider.proposals(&buffer, start, &word).await }).expect("the proposals"); + (0..model.n_items()) + .filter_map(|index| model.item(index).and_downcast::()) + .collect() +} + +fn labels(proposals: &[SqlProposal]) -> Vec<&str> { + proposals.iter().map(SqlProposal::label).collect() +} + +fn find<'a>(proposals: &'a [SqlProposal], label: &str) -> &'a SqlProposal { + proposals + .iter() + .find(|proposal| proposal.label() == label) + .unwrap_or_else(|| panic!("no proposal {label} in {:?}", labels(proposals))) +} + +#[gtk4::test] +fn populate_after_from_contains_users() { + let harness = connected( + ConnectionScript::default(), + vec![ + object("users", ObjectKind::Table), + object("active_users", ObjectKind::View), + object("users_seq", ObjectKind::Sequence), + object("refresh_users", ObjectKind::Function), + object("orders", ObjectKind::Table), + ], + ); + + let found = proposals(&harness, "SELECT * FROM us|"); + + assert_eq!(labels(&found), ["users", "active_users"]); + assert_eq!(find(&found, "active_users").kind(), &ProposalKind::View); +} + +#[gtk4::test] +fn populate_alias_dot_lists_columns_with_types() { + let mut script = ConnectionScript::default(); + script.fetch_columns.when_sql_contains( + "users", + FakeReply::ok(vec![column("id", "integer"), column("email", "text")]), + ); + let harness = connected(script, vec![object("users", ObjectKind::Table)]); + + let found = proposals(&harness, "SELECT u.| FROM users u"); + + assert_eq!(labels(&found), ["id", "email"]); + let id = find(&found, "id"); + assert_eq!( + id.kind(), + &ProposalKind::Column { + data_type: "integer".to_owned(), + table: TableRef::new(Some("public".to_owned()), "users"), + }, + "the column was read under a name the catalog does not share" + ); + assert_eq!(column_text(id, CompletionColumn::After).as_deref(), Some("integer")); + assert_eq!( + column_text(id, CompletionColumn::Comment).as_deref(), + Some("public.users") + ); +} + +#[gtk4::test] +fn general_scope_offers_the_statements_columns_and_keywords() { + let mut script = ConnectionScript::default(); + script + .fetch_columns + .when_sql_contains("users", FakeReply::ok(vec![column("name", "text")])); + let harness = connected(script, vec![object("users", ObjectKind::Table)]); + + let found = proposals(&harness, "SELECT | FROM users"); + + assert!(labels(&found).contains(&"name")); + assert!(labels(&found).contains(&"users")); + assert!(labels(&found).contains(&"WHERE")); + assert_eq!(labels(&found).first(), Some(&"name"), "a column is not first"); +} + +#[gtk4::test] +fn keyword_case_follows_prefix() { + let harness = unconnected(); + + for (typed, expected) in [("sel", "select"), ("SEL", "SELECT")] { + let found = proposals(&harness, &format!("{typed}|")); + let select = find(&found, "SELECT"); + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text(typed); + + replace_word( + &buffer, + buffer.start_iter(), + buffer.end_iter(), + &select.insert_text(typed), + ); + + let (start, end) = buffer.bounds(); + assert_eq!(buffer.text(&start, &end, false), expected); + assert_eq!(select.shown_text(typed), expected); + } +} + +#[gtk4::test] +fn activate_replaces_word_as_one_undo_step() { + let buffer = sourceview5::Buffer::new(None); + buffer.begin_irreversible_action(); + buffer.set_text("SELECT * FROM us WHERE true"); + buffer.end_irreversible_action(); + let text = |buffer: &sourceview5::Buffer| { + let (start, end) = buffer.bounds(); + buffer.text(&start, &end, false).to_string() + }; + + replace_word( + buffer.upcast_ref(), + buffer.iter_at_offset(14), + buffer.iter_at_offset(16), + "users", + ); + assert_eq!(text(&buffer), "SELECT * FROM users WHERE true"); + + buffer.undo(); + assert_eq!(text(&buffer), "SELECT * FROM us WHERE true"); + assert!(!buffer.can_undo(), "the completion took more than one undo step"); +} + +#[gtk4::test] +fn a_cursor_inside_a_word_replaces_all_of_it() { + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text("SELECT * FROM usrs"); + + replace_word(&buffer, buffer.iter_at_offset(14), buffer.iter_at_offset(16), "users"); + + let (start, end) = buffer.bounds(); + assert_eq!(buffer.text(&start, &end, false), "SELECT * FROM users"); +} + +#[gtk4::test] +fn identifier_needing_quotes_is_quoted_by_dialect() { + let harness = connected( + ConnectionScript::default(), + vec![ + object("Order Items", ObjectKind::Table), + object("users", ObjectKind::Table), + ], + ); + + let found = proposals(&harness, "SELECT * FROM |"); + + assert_eq!(find(&found, "Order Items").insert_text(""), "\"Order Items\""); + assert_eq!(find(&found, "users").insert_text(""), "users"); +} + +#[gtk4::test] +fn nothing_is_proposed_inside_a_string() { + let harness = connected(ConnectionScript::default(), vec![object("users", ObjectKind::Table)]); + + assert!(proposals(&harness, "SELECT 'us|").is_empty()); +} + +#[gtk4::test] +fn refilter_narrows_the_list_without_reading_again() { + let harness = connected( + ConnectionScript::default(), + vec![object("users", ObjectKind::Table), object("orders", ObjectKind::Table)], + ); + let (text, start, word) = word_before("SELECT * FROM |"); + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text(&text); + let provider = harness.provider.clone(); + let model = + finish_on_main(WAIT, async move { provider.proposals(&buffer, start, &word).await }).expect("the proposals"); + assert_eq!(model.n_items(), 2); + + harness.provider.refilter("ord", &model); + + assert_eq!(model.n_items(), 1); + assert_eq!( + model + .item(0) + .and_downcast::() + .map(|proposal| proposal.label().to_owned()), + Some("orders".to_owned()) + ); + harness.provider.refilter("", &model); + assert_eq!(model.n_items(), 2); +} + +/// The texts of the completion list GtkSourceView shows for `view`. +fn shown(view: &sourceview5::View) -> Vec { + descendants(view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter_map(|cell| cell.text()) + .map(|text| text.to_string()) + .collect() +} + +/// An editor in a window on screen, with the focus in its text. +struct OnScreen { + editor: SqlEditor, + window: gtk4::Window, + _session: FakeSession, + _settings: MemorySettings, + _runtime: crate::runtime::AppRuntime, +} + +impl OnScreen { + fn new(script: ConnectionScript, objects: Vec, text: &str) -> Self { + crate::register_test_resources(); + let runtime = test_runtime(); + let settings = MemorySettings::new(); + let fake = FakeSession::start(script, [], runtime.tasks()); + fake.session.catalog().commit(Vec::new(), objects); + let active = ActiveSession::new(); + active.replace(fake.session.clone()); + let editor = SqlEditor::new(SqlEditorInit { + initial_query: Some(text.to_owned()), + settings: settings.get().clone(), + history: None, + tasks: runtime.tasks(), + session: active, + hold: Rc::new(tablepro_session::runtime::CountingHold::new()), + close_timeout: Duration::from_secs(5), + }); + let window = gtk4::Window::new(); + window.set_child(Some(&editor)); + window.present(); + let view = editor.imp().source_view.get(); + view.grab_focus(); + // GtkSourceView starts no completion in a view that is not on + // screen with the focus. + wait_until(WAIT, || view.is_mapped() && view.has_focus()).expect("the view on screen with the focus"); + Self { + editor, + window, + _session: fake, + _settings: settings, + _runtime: runtime, + } + } + + fn view(&self) -> sourceview5::View { + self.editor.imp().source_view.get() + } + + /// Wait for the completion list to show `text`, and say what it + /// showed when it did not. + fn wait_for(&self, text: &str) -> Result<(), Vec> { + let view = self.view(); + wait_until(WAIT, || shown(&view).iter().any(|shown| shown == text)).map_err(|_| shown(&view)) + } +} + +impl Drop for OnScreen { + fn drop(&mut self) { + self.window.destroy(); + } +} + +#[gtk4::test] +fn the_editor_shows_catalog_tables_in_its_completion() { + let screen = OnScreen::new( + ConnectionScript::default(), + vec![object("users", ObjectKind::Table)], + "SELECT * FROM us", + ); + let buffer = screen.view().buffer(); + buffer.place_cursor(&buffer.end_iter()); + + screen.view().completion().show(); + + screen.wait_for("users").expect("users in the list"); +} + +#[gtk4::test] +fn typing_a_dot_after_an_alias_lists_its_columns_with_types() { + let mut script = ConnectionScript::default(); + script + .fetch_columns + .when_sql_contains("users", FakeReply::ok(vec![column("id", "integer")])); + let screen = OnScreen::new(script, vec![object("users", ObjectKind::Table)], "SELECT FROM users u"); + let buffer = screen.view().buffer(); + buffer.place_cursor(&buffer.iter_at_offset(7)); + + buffer.insert_interactive_at_cursor("u", true); + buffer.insert_interactive_at_cursor(".", true); + + screen.wait_for("id").expect("the alias's column in the list"); + screen.wait_for("integer").expect("the column's type beside it"); +} diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index d229e43e15..c538fcd2d6 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -3,6 +3,7 @@ //! gets a line in the results list. mod buffer_edit; +mod completion; mod editor_session_slot; mod format_plan; mod imp; @@ -51,7 +52,6 @@ glib::wrapper! { } pub struct SqlEditorInit { - pub schema_buffer: gtk4::TextBuffer, pub initial_query: Option, pub settings: Rc, pub history: Option, @@ -94,7 +94,7 @@ impl SqlEditor { buffer.set_text(&initial_text); imp.source_view.set_buffer(Some(&buffer)); editor.connect_theme(&init.settings); - editor.connect_completion(&init.schema_buffer, &buffer); + editor.connect_completion(&buffer); editor.connect_buffer(&buffer); editor.install_actions(); editor.install_shortcuts(); @@ -533,11 +533,15 @@ impl SqlEditor { self.imp().app_handlers.replace(handlers); } - fn connect_completion(&self, schema_buffer: >k4::TextBuffer, buffer: &sourceview5::Buffer) { - let provider = sourceview5::CompletionWords::new(Some("SQL")); - provider.register(schema_buffer); - provider.register(buffer); - self.imp().source_view.completion().add_provider(&provider); + fn connect_completion(&self, buffer: &sourceview5::Buffer) { + let completion = self.imp().source_view.completion(); + if let Some(deps) = self.deps() { + let catalog = completion::SqlCompletionProvider::new(deps.session.clone(), deps.tasks.clone()); + completion.add_provider(&catalog); + } + let words = sourceview5::CompletionWords::new(Some(&crate::i18n::pgettext("completion group", "Document"))); + words.register(buffer); + completion.add_provider(&words); } fn connect_buffer(&self, buffer: &sourceview5::Buffer) { @@ -842,31 +846,6 @@ fn toggle_line_comment(buffer: >k4::TextBuffer) { buffer.end_user_action(); } -pub const SQL_KEYWORDS: &str = "\ -SELECT FROM WHERE INSERT INTO VALUES UPDATE SET DELETE \ -JOIN INNER LEFT RIGHT FULL OUTER ON USING UNION INTERSECT EXCEPT \ -GROUP BY ORDER HAVING LIMIT OFFSET DISTINCT ALL AS WITH \ -CREATE TABLE INDEX VIEW DROP ALTER TRUNCATE \ -PRIMARY KEY FOREIGN REFERENCES UNIQUE NOT NULL DEFAULT CHECK \ -AND OR IS LIKE IN BETWEEN EXISTS ANY \ -COUNT SUM AVG MIN MAX CASE WHEN THEN ELSE END \ -TRUE FALSE ASC DESC RETURNING"; - -pub fn build_schema_buffer() -> gtk4::TextBuffer { - let buf = gtk4::TextBuffer::new(None); - buf.set_text(SQL_KEYWORDS); - buf -} - -pub fn update_schema_buffer(buffer: >k4::TextBuffer, schema_words: &[String]) { - let mut text = String::from(SQL_KEYWORDS); - for w in schema_words { - text.push(' '); - text.push_str(w); - } - buffer.set_text(&text); -} - /// File-name stem for an editor export, taken from the statement that /// produced the results: exporting two queries in a row proposes two /// different files instead of offering to overwrite the first. diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 06026c6613..00ca6fcfb2 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -45,7 +45,6 @@ fn editor_with(runs: Replies, text: &str) -> Harness { let active = ActiveSession::new(); active.replace(session_with(fake.clone(), runtime.tasks())); let editor = SqlEditor::new(SqlEditorInit { - schema_buffer: build_schema_buffer(), initial_query: Some(text.to_owned()), settings: settings.get().clone(), history: None, @@ -306,7 +305,6 @@ fn the_icon_buttons_have_accessible_names() { fn unconnected_editor(settings: &MemorySettings, tasks: Tasks) -> SqlEditor { crate::register_test_resources(); SqlEditor::new(SqlEditorInit { - schema_buffer: build_schema_buffer(), initial_query: None, settings: settings.get().clone(), history: None, diff --git a/linux/crates/app/src/ui/icons.rs b/linux/crates/app/src/ui/icons.rs index 05a7b9defc..32b9bdfa5f 100644 --- a/linux/crates/app/src/ui/icons.rs +++ b/linux/crates/app/src/ui/icons.rs @@ -8,6 +8,7 @@ pub const DOCUMENT_OPEN_RECENT: &str = "document-open-recent-symbolic"; pub const DOCUMENT_SAVE: &str = "document-save-symbolic"; pub const EDIT_CLEAR: &str = "edit-clear-symbolic"; pub const EDIT_COPY: &str = "edit-copy-symbolic"; +pub const FOLDER: &str = "folder-symbolic"; pub const GO_FIRST: &str = "go-first-symbolic"; pub const GO_LAST: &str = "go-last-symbolic"; pub const GO_NEXT: &str = "go-next-symbolic"; @@ -22,6 +23,7 @@ pub const PROCESS_STOP: &str = "process-stop-symbolic"; pub const STATEMENT_PENDING: &str = "statement-pending-symbolic"; pub const SIDEBAR_SHOW: &str = "sidebar-show-symbolic"; pub const SUCCESS: &str = "object-select-symbolic"; +pub const SYSTEM_RUN: &str = "system-run-symbolic"; pub const SYSTEM_SEARCH: &str = "system-search-symbolic"; pub const TABLE_RELATION: &str = "table-relation-symbolic"; pub const TAB_NEW: &str = "tab-new-symbolic"; @@ -32,6 +34,7 @@ pub const VIEW_GRID: &str = "view-grid-symbolic"; pub const VIEW_LIST: &str = "view-list-symbolic"; pub const VIEW_MORE: &str = "view-more-symbolic"; pub const VIEW_PIN: &str = "view-pin-symbolic"; +pub const VIEW_REVEAL: &str = "view-reveal-symbolic"; pub const VIEW_SORT_ASCENDING: &str = "view-sort-ascending-symbolic"; pub const WINDOW_CLOSE: &str = "window-close-symbolic"; @@ -50,6 +53,7 @@ pub const ALL: &[&str] = &[ DOCUMENT_SAVE, EDIT_CLEAR, EDIT_COPY, + FOLDER, GO_FIRST, GO_LAST, GO_NEXT, @@ -64,6 +68,7 @@ pub const ALL: &[&str] = &[ SIDEBAR_SHOW, STATEMENT_PENDING, SUCCESS, + SYSTEM_RUN, SYSTEM_SEARCH, TABLE_RELATION, TAB_NEW, @@ -74,6 +79,7 @@ pub const ALL: &[&str] = &[ VIEW_LIST, VIEW_MORE, VIEW_PIN, + VIEW_REVEAL, VIEW_SORT_ASCENDING, WINDOW_CLOSE, ]; diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 11581bdbfc..34540e11fc 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -90,6 +90,16 @@ crates/app/src/ui/credential_prompt/prompt_form.rs crates/app/src/ui/credential_prompt/prompt_text.rs crates/app/src/ui/credential_prompt/prompter.rs crates/app/src/ui/editor/buffer_edit.rs +crates/app/src/ui/editor/completion/catalog_read.rs +crates/app/src/ui/editor/completion/completion_scope.rs +crates/app/src/ui/editor/completion/fuzzy.rs +crates/app/src/ui/editor/completion/mod.rs +crates/app/src/ui/editor/completion/proposal_kind.rs +crates/app/src/ui/editor/completion/sql_completion_provider.rs +crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs +crates/app/src/ui/editor/completion/sql_proposal.rs +crates/app/src/ui/editor/completion/sql_proposal_imp.rs +crates/app/src/ui/editor/completion/tests.rs crates/app/src/ui/editor/editor_session_slot.rs crates/app/src/ui/editor/format_plan.rs crates/app/src/ui/editor/imp.rs From 1f1a16f411a067a3de44d6e266d49e2d79f71c1c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 23:13:27 +0700 Subject: [PATCH 109/178] fix(linux-core): quote the words each engine reserves, not completions --- linux/crates/core/src/sql_syntax/keywords.rs | 54 ----- linux/crates/core/src/sql_syntax/mod.rs | 1 + .../core/src/sql_syntax/reserved_words.rs | 197 ++++++++++++++++++ 3 files changed, 198 insertions(+), 54 deletions(-) create mode 100644 linux/crates/core/src/sql_syntax/reserved_words.rs diff --git a/linux/crates/core/src/sql_syntax/keywords.rs b/linux/crates/core/src/sql_syntax/keywords.rs index 58640e1e4a..4f3717f6ad 100644 --- a/linux/crates/core/src/sql_syntax/keywords.rs +++ b/linux/crates/core/src/sql_syntax/keywords.rs @@ -150,33 +150,6 @@ impl SqlGrammar { }; COMMON.iter().chain(own).copied() } - - /// Whether `name` reads as the same identifier written without - /// quotes. - /// - /// PostgreSQL folds an unquoted name to lower case, so a capital - /// changes the name there. A keyword the editor completes is quoted - /// too, because most of them are reserved somewhere. - pub fn is_bare_identifier(self, name: &str) -> bool { - let mut chars = name.chars(); - let Some(first) = chars.next() else { - return false; - }; - let letter = |c: char| match self { - Self::ClickHouse => c.is_ascii_alphabetic(), - _ => c.is_alphabetic(), - }; - let start = letter(first) || first == '_' || (self == Self::MsSql && first == '#'); - let part = |c: char| { - letter(c) - || c.is_ascii_digit() - || c == '_' - || (c == '$' && self != Self::ClickHouse) - || (self == Self::MsSql && matches!(c, '#' | '@')) - }; - let folds = self == Self::PostgreSql && name.chars().any(char::is_uppercase); - start && chars.all(part) && !folds && !self.keywords().any(|keyword| keyword.eq_ignore_ascii_case(name)) - } } #[cfg(test)] @@ -208,31 +181,4 @@ mod tests { } } } - - #[test] - fn plain_names_stay_bare() { - for grammar in SqlGrammar::ALL { - assert!(grammar.is_bare_identifier("users"), "{grammar:?}"); - assert!(grammar.is_bare_identifier("order_items_2"), "{grammar:?}"); - assert!(grammar.is_bare_identifier("_draft"), "{grammar:?}"); - } - assert!(SqlGrammar::PostgreSql.is_bare_identifier("đơn_hàng")); - assert!(SqlGrammar::MySql.is_bare_identifier("Users")); - assert!(SqlGrammar::MsSql.is_bare_identifier("#scratch")); - } - - #[test] - fn names_that_read_differently_bare_need_quotes() { - for grammar in SqlGrammar::ALL { - assert!(!grammar.is_bare_identifier(""), "{grammar:?}"); - assert!(!grammar.is_bare_identifier("order items"), "{grammar:?}"); - assert!(!grammar.is_bare_identifier("2fa"), "{grammar:?}"); - assert!(!grammar.is_bare_identifier("order"), "{grammar:?}"); - assert!(!grammar.is_bare_identifier("a-b"), "{grammar:?}"); - } - assert!(!SqlGrammar::PostgreSql.is_bare_identifier("Users")); - assert!(!SqlGrammar::PostgreSql.is_bare_identifier("user")); - assert!(!SqlGrammar::ClickHouse.is_bare_identifier("đơn")); - assert!(!SqlGrammar::ClickHouse.is_bare_identifier("a$b")); - } } diff --git a/linux/crates/core/src/sql_syntax/mod.rs b/linux/crates/core/src/sql_syntax/mod.rs index e6de2120b5..a3ebc84d12 100644 --- a/linux/crates/core/src/sql_syntax/mod.rs +++ b/linux/crates/core/src/sql_syntax/mod.rs @@ -4,6 +4,7 @@ mod keywords; mod literal_token; mod parse_expression; mod parse_type; +mod reserved_words; pub mod script; pub use errors::{ExpressionSyntaxError, TypeSyntaxError}; diff --git a/linux/crates/core/src/sql_syntax/reserved_words.rs b/linux/crates/core/src/sql_syntax/reserved_words.rs new file mode 100644 index 0000000000..48460febc8 --- /dev/null +++ b/linux/crates/core/src/sql_syntax/reserved_words.rs @@ -0,0 +1,197 @@ +//! The words each engine will not read as a plain name. +//! +//! PostgreSQL's reserved and type-or-function categories, the reserved +//! words of MySQL 8.4 and MariaDB, the T-SQL reserved keywords and all +//! of SQLite's keywords. ClickHouse reserves nothing, so its list holds +//! the words its parser takes as a clause, an operator or a literal +//! wherever they stand. + +use crate::sql_syntax::SqlGrammar; + +const POSTGRESQL: &str = " + ALL ANALYSE ANALYZE AND ANY ARRAY AS ASC ASYMMETRIC AUTHORIZATION BINARY BOTH CASE CAST CHECK COLLATE + COLLATION COLUMN CONCURRENTLY CONSTRAINT CREATE CROSS CURRENT_CATALOG CURRENT_DATE CURRENT_ROLE + CURRENT_SCHEMA CURRENT_TIME CURRENT_TIMESTAMP CURRENT_USER DEFAULT DEFERRABLE DESC DISTINCT DO ELSE END + EXCEPT FALSE FETCH FOR FOREIGN FREEZE FROM FULL GRANT GROUP HAVING ILIKE IN INITIALLY INNER INTERSECT + INTO IS ISNULL JOIN LATERAL LEADING LEFT LIKE LIMIT LOCALTIME LOCALTIMESTAMP NATURAL NOT NOTNULL NULL + OFFSET ON ONLY OR ORDER OUTER OVERLAPS PLACING PRIMARY REFERENCES RETURNING RIGHT SELECT SESSION_USER + SIMILAR SOME SYMMETRIC SYSTEM_USER TABLE TABLESAMPLE THEN TO TRAILING TRUE UNION UNIQUE USER USING + VARIADIC VERBOSE WHEN WHERE WINDOW WITH +"; + +const MYSQL: &str = " + ACCESSIBLE ADD ALL ALTER ANALYZE AND AS ASC ASENSITIVE BEFORE BETWEEN BIGINT BINARY BLOB BOTH BY CALL + CASCADE CASE CHANGE CHAR CHARACTER CHECK COLLATE COLUMN CONDITION CONSTRAINT CONTINUE CONVERT CREATE + CROSS CUBE CUME_DIST CURRENT_DATE CURRENT_ROLE CURRENT_TIME CURRENT_TIMESTAMP CURRENT_USER CURSOR + DATABASE DATABASES DAY_HOUR DAY_MICROSECOND DAY_MINUTE DAY_SECOND DEC DECIMAL DECLARE DEFAULT DELAYED + DELETE DELETE_DOMAIN_ID DENSE_RANK DESC DESCRIBE DETERMINISTIC DISTINCT DISTINCTROW DIV DOUBLE + DO_DOMAIN_IDS DROP DUAL EACH ELSE ELSEIF EMPTY ENCLOSED ESCAPED EXCEPT EXISTS EXIT EXPLAIN FALSE FETCH + FIRST_VALUE FLOAT FLOAT4 FLOAT8 FOR FORCE FOREIGN FROM FULLTEXT FUNCTION GENERAL GENERATED GET GRANT + GROUP GROUPING GROUPS HAVING HIGH_PRIORITY HOUR_MICROSECOND HOUR_MINUTE HOUR_SECOND IF IGNORE + IGNORE_DOMAIN_IDS IGNORE_SERVER_IDS IN INDEX INFILE INNER INOUT INSENSITIVE INSERT INT INT1 INT2 INT3 + INT4 INT8 INTEGER INTERSECT INTERVAL INTO IO_AFTER_GTIDS IO_BEFORE_GTIDS IS ITERATE JOIN JSON_TABLE KEY + KEYS KILL LAG LAST_VALUE LATERAL LEAD LEADING LEAVE LEFT LIKE LIMIT LINEAR LINES LOAD LOCALTIME + LOCALTIMESTAMP LOCK LONG LONGBLOB LONGTEXT LOOP LOW_PRIORITY MASTER_BIND MASTER_HEARTBEAT_PERIOD + MASTER_SSL_VERIFY_SERVER_CERT MATCH MAXVALUE MEDIUMBLOB MEDIUMINT MEDIUMTEXT MIDDLEINT + MINUTE_MICROSECOND MINUTE_SECOND MOD MODIFIES NATURAL NOT NO_WRITE_TO_BINLOG NTH_VALUE NTILE NULL + NUMERIC OF OFFSET ON OPTIMIZE OPTIMIZER_COSTS OPTION OPTIONALLY OR ORDER OUT OUTER OUTFILE OVER + PAGE_CHECKSUM PARALLEL PARSE_VCOL_EXPR PARTITION PERCENT_RANK PRECISION PRIMARY PROCEDURE PURGE QUALIFY + RANGE RANK READ READS READ_WRITE REAL RECURSIVE REFERENCES REF_SYSTEM_ID REGEXP RELEASE RENAME REPEAT + REPLACE REQUIRE RESIGNAL RESTRICT RETURN RETURNING REVOKE RIGHT RLIKE ROW ROWS ROW_NUMBER SCHEMA SCHEMAS + SECOND_MICROSECOND SELECT SENSITIVE SEPARATOR SET SHOW SIGNAL SLOW SMALLINT SPATIAL SPECIFIC SQL + SQLEXCEPTION SQLSTATE SQLWARNING SQL_BIG_RESULT SQL_CALC_FOUND_ROWS SQL_SMALL_RESULT SSL STARTING + STATS_AUTO_RECALC STATS_PERSISTENT STATS_SAMPLE_PAGES STORED STRAIGHT_JOIN SYSTEM TABLE TABLESAMPLE + TERMINATED THEN TINYBLOB TINYINT TINYTEXT TO TRAILING TRIGGER TRUE UNDO UNION UNIQUE UNLOCK UNSIGNED + UPDATE USAGE USE USING UTC_DATE UTC_TIME UTC_TIMESTAMP VALUES VARBINARY VARCHAR VARCHARACTER VARYING + VIRTUAL WHEN WHERE WHILE WINDOW WITH WRITE XOR YEAR_MONTH ZEROFILL +"; + +const SQLITE: &str = " + ABORT ACTION ADD AFTER ALL ALTER ALWAYS ANALYZE AND AS ASC ATTACH AUTOINCREMENT BEFORE BEGIN BETWEEN BY + CASCADE CASE CAST CHECK COLLATE COLUMN COMMIT CONFLICT CONSTRAINT CREATE CROSS CURRENT CURRENT_DATE + CURRENT_TIME CURRENT_TIMESTAMP DATABASE DEFAULT DEFERRABLE DEFERRED DELETE DESC DETACH DISTINCT DO DROP + EACH ELSE END ESCAPE EXCEPT EXCLUDE EXCLUSIVE EXISTS EXPLAIN FAIL FILTER FIRST FOLLOWING FOR FOREIGN + FROM FULL GENERATED GLOB GROUP GROUPS HAVING IF IGNORE IMMEDIATE IN INDEX INDEXED INITIALLY INNER INSERT + INSTEAD INTERSECT INTO IS ISNULL JOIN KEY LAST LEFT LIKE LIMIT MATCH MATERIALIZED NATURAL NO NOT NOTHING + NOTNULL NULL NULLS OF OFFSET ON OR ORDER OTHERS OUTER OVER PARTITION PLAN PRAGMA PRECEDING PRIMARY QUERY + RAISE RANGE RECURSIVE REFERENCES REGEXP REINDEX RELEASE RENAME REPLACE RESTRICT RETURNING RIGHT ROLLBACK + ROW ROWS SAVEPOINT SELECT SET TABLE TEMP TEMPORARY THEN TIES TO TRANSACTION TRIGGER UNBOUNDED UNION + UNIQUE UPDATE USING VACUUM VALUES VIEW VIRTUAL WHEN WHERE WINDOW WITH WITHOUT +"; + +const MSSQL: &str = " + ADD ALL ALTER AND ANY AS ASC AUTHORIZATION BACKUP BEGIN BETWEEN BREAK BROWSE BULK BY CASCADE CASE CHECK + CHECKPOINT CLOSE CLUSTERED COALESCE COLLATE COLUMN COMMIT COMPUTE CONSTRAINT CONTAINS CONTAINSTABLE + CONTINUE CONVERT CREATE CROSS CURRENT CURRENT_DATE CURRENT_TIME CURRENT_TIMESTAMP CURRENT_USER CURSOR + DATABASE DBCC DEALLOCATE DECLARE DEFAULT DELETE DENY DESC DISK DISTINCT DISTRIBUTED DOUBLE DROP DUMP + ELSE END ERRLVL ESCAPE EXCEPT EXEC EXECUTE EXISTS EXIT EXTERNAL FETCH FILE FILLFACTOR FOR FOREIGN + FREETEXT FREETEXTTABLE FROM FULL FUNCTION GOTO GRANT GROUP HAVING HOLDLOCK IDENTITY IDENTITYCOL + IDENTITY_INSERT IF IN INDEX INNER INSERT INTERSECT INTO IS JOIN KEY KILL LEFT LIKE LINENO LOAD MERGE + NATIONAL NOCHECK NONCLUSTERED NOT NULL NULLIF OF OFF OFFSETS ON OPEN OPENDATASOURCE OPENQUERY OPENROWSET + OPENXML OPTION OR ORDER OUTER OVER PERCENT PIVOT PLAN PRECISION PRIMARY PRINT PROC PROCEDURE PUBLIC + RAISERROR READ READTEXT RECONFIGURE REFERENCES REPLICATION RESTORE RESTRICT RETURN REVERT REVOKE RIGHT + ROLLBACK ROWCOUNT ROWGUIDCOL RULE SAVE SCHEMA SECURITYAUDIT SELECT SEMANTICKEYPHRASETABLE + SEMANTICSIMILARITYDETAILSTABLE SEMANTICSIMILARITYTABLE SESSION_USER SET SETUSER SHUTDOWN SOME STATISTICS + SYSTEM_USER TABLE TABLESAMPLE TEXTSIZE THEN TO TOP TRAN TRANSACTION TRIGGER TRUNCATE TRY_CONVERT TSEQUAL + UNION UNIQUE UNPIVOT UPDATE UPDATETEXT USE USER VALUES VARYING VIEW WAITFOR WHEN WHERE WHILE WITH WITHIN + WRITETEXT +"; + +const CLICKHOUSE: &str = " + ALL AND ANTI ANY ARRAY AS ASC ASOF BETWEEN BY CASE CAST CROSS DESC DISTINCT ELSE END EXCEPT EXISTS FALSE + FINAL FORMAT FROM FULL GLOBAL GROUP HAVING ILIKE IN INF INNER INTERSECT INTERVAL INTO IS JOIN LEFT LIKE + LIMIT NAN NOT NULL OFFSET ON ONLY OR ORDER OUTER PASTE PREWHERE QUALIFY RIGHT SAMPLE SELECT SEMI + SETTINGS THEN TO TOP TRUE UNION USING WHEN WHERE WINDOW WITH +"; + +impl SqlGrammar { + /// Whether `name` reads as the same identifier written without + /// quotes. + /// + /// PostgreSQL folds an unquoted name to lower case, so a capital + /// changes the name there. + pub fn is_bare_identifier(self, name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + let letter = |c: char| match self { + Self::ClickHouse => c.is_ascii_alphabetic(), + _ => c.is_alphabetic(), + }; + let start = letter(first) || first == '_' || (self == Self::MsSql && first == '#'); + let part = |c: char| { + letter(c) + || c.is_ascii_digit() + || c == '_' + || (c == '$' && self != Self::ClickHouse) + || (self == Self::MsSql && matches!(c, '#' | '@')) + }; + let folds = self == Self::PostgreSql && name.chars().any(char::is_uppercase); + start && chars.all(part) && !folds && !self.is_reserved(name) + } + + fn is_reserved(self, word: &str) -> bool { + self.reserved_words() + .split_ascii_whitespace() + .any(|reserved| reserved.eq_ignore_ascii_case(word)) + } + + fn reserved_words(self) -> &'static str { + match self { + Self::PostgreSql => POSTGRESQL, + Self::MySql => MYSQL, + Self::Sqlite => SQLITE, + Self::MsSql => MSSQL, + Self::ClickHouse => CLICKHOUSE, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_list_is_upper_case_sorted_and_listed_once() { + for grammar in SqlGrammar::ALL { + let words: Vec<&str> = grammar.reserved_words().split_ascii_whitespace().collect(); + for pair in words.windows(2) { + assert!(pair[0] < pair[1], "{} before {} for {grammar:?}", pair[0], pair[1]); + } + for word in words { + assert_eq!(word, word.to_ascii_uppercase(), "{grammar:?}"); + } + } + } + + #[test] + fn plain_names_stay_bare() { + for grammar in SqlGrammar::ALL { + assert!(grammar.is_bare_identifier("users"), "{grammar:?}"); + assert!(grammar.is_bare_identifier("order_items_2"), "{grammar:?}"); + assert!(grammar.is_bare_identifier("_draft"), "{grammar:?}"); + } + assert!(SqlGrammar::PostgreSql.is_bare_identifier("đơn_hàng")); + assert!(SqlGrammar::MySql.is_bare_identifier("Users")); + assert!(SqlGrammar::MsSql.is_bare_identifier("#scratch")); + } + + #[test] + fn a_function_name_the_editor_completes_stays_bare() { + for grammar in [SqlGrammar::PostgreSql, SqlGrammar::MySql, SqlGrammar::ClickHouse] { + assert!(grammar.is_bare_identifier("count"), "{grammar:?}"); + } + assert!(SqlGrammar::ClickHouse.is_bare_identifier("engine")); + } + + #[test] + fn names_that_read_differently_bare_need_quotes() { + for grammar in SqlGrammar::ALL { + assert!(!grammar.is_bare_identifier(""), "{grammar:?}"); + assert!(!grammar.is_bare_identifier("order items"), "{grammar:?}"); + assert!(!grammar.is_bare_identifier("2fa"), "{grammar:?}"); + assert!(!grammar.is_bare_identifier("order"), "{grammar:?}"); + assert!(!grammar.is_bare_identifier("a-b"), "{grammar:?}"); + } + assert!(!SqlGrammar::PostgreSql.is_bare_identifier("Users")); + assert!(!SqlGrammar::ClickHouse.is_bare_identifier("đơn")); + assert!(!SqlGrammar::ClickHouse.is_bare_identifier("a$b")); + } + + #[test] + fn reserved_words_the_editor_does_not_complete_need_quotes() { + for grammar in SqlGrammar::ALL { + assert!(!grammar.is_bare_identifier("to"), "{grammar:?}"); + assert!(!grammar.is_bare_identifier("TO"), "{grammar:?}"); + } + assert!(!SqlGrammar::MsSql.is_bare_identifier("user")); + assert!(!SqlGrammar::PostgreSql.is_bare_identifier("user")); + assert!(!SqlGrammar::PostgreSql.is_bare_identifier("current_date")); + for word in ["rank", "range", "interval", "condition", "groups", "rows", "system"] { + assert!(!SqlGrammar::MySql.is_bare_identifier(word), "{word}"); + } + assert!(!SqlGrammar::Sqlite.is_bare_identifier("current_timestamp")); + assert!(!SqlGrammar::ClickHouse.is_bare_identifier("nan")); + } +} From 87efe15ad1d6ed7cdf7e61f8250c1d80e669f5bf Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 23:15:27 +0700 Subject: [PATCH 110/178] refactor(linux)!: generate DDL through per-engine DdlDialect plans The structure tab plans against the table's definition through the connection's DdlDialect, and the DDL conformance suite runs those plans on every engine. A table rename keeps the table in its schema on MySQL and uses RENAME TABLE on ClickHouse. --- linux/CHANGELOG.md | 10 + linux/crates/app/src/services/mod.rs | 1 + .../app/src/services/pending_structure.rs | 145 + .../app/src/services/structure_tracker.rs | 79 +- .../src/services/structure_tracker_handle.rs | 13 +- .../services/structure_tracker_registry.rs | 35 +- linux/crates/app/src/ui/app/mod.rs | 90 +- .../app/src/ui/app/sidebar_section_header.rs | 69 + linux/crates/app/src/ui/app/structure.rs | 329 +-- linux/crates/app/src/ui/app/workspace_tabs.rs | 10 +- .../app/src/ui/structure_tab/columns.rs | 304 +- .../app/src/ui/structure_tab/ddl_text.rs | 92 + linux/crates/app/src/ui/structure_tab/fks.rs | 17 +- linux/crates/app/src/ui/structure_tab/imp.rs | 4 +- .../app/src/ui/structure_tab/indexes.rs | 12 +- linux/crates/app/src/ui/structure_tab/mod.rs | 791 +++--- .../crates/app/src/ui/structure_tab/tests.rs | 194 +- .../app/src/ui/structure_tab_dialogs.rs | 11 +- linux/crates/core/src/ddl/build_ddl_error.rs | 34 + .../crates/core/src/ddl/changed_attributes.rs | 44 + .../crates/core/src/ddl/column_alteration.rs | 10 + .../core/src/ddl/column_capabilities.rs | 101 + linux/crates/core/src/ddl/column_rename.rs | 6 + linux/crates/core/src/ddl/ddl_capabilities.rs | 3 + linux/crates/core/src/ddl/ddl_dialect.rs | 137 +- linux/crates/core/src/ddl/ddl_operation.rs | 55 + linux/crates/core/src/ddl/ddl_plan.rs | 81 + linux/crates/core/src/ddl/ddl_sql.rs | 143 + linux/crates/core/src/ddl/default_text.rs | 49 + linux/crates/core/src/ddl/diff_table.rs | 112 + linux/crates/core/src/ddl/draft_column.rs | 55 + linux/crates/core/src/ddl/draft_problem.rs | 31 + linux/crates/core/src/ddl/draft_type.rs | 69 + .../crates/core/src/ddl/drop_table_options.rs | 8 + linux/crates/core/src/ddl/mod.rs | 44 +- linux/crates/core/src/ddl/permission.rs | 38 + .../crates/core/src/ddl/primary_key_change.rs | 11 + linux/crates/core/src/ddl/refusal_reason.rs | 44 + linux/crates/core/src/ddl/table_changes.rs | 176 ++ linux/crates/core/src/ddl/table_draft.rs | 149 + linux/crates/core/src/ddl/tests.rs | 619 ++++ linux/crates/core/src/dialect/test_dialect.rs | 25 - linux/crates/core/src/lib.rs | 1 - linux/crates/core/src/sql_ddl.rs | 2489 ----------------- .../core/src/statement/sql_build_error.rs | 7 + linux/crates/drivers/clickhouse/src/ddl.rs | 466 +++ .../crates/drivers/clickhouse/src/dialect.rs | 40 +- linux/crates/drivers/clickhouse/src/lib.rs | 1 + .../tests/integration/conformance_engine.rs | 52 +- linux/crates/drivers/mssql/src/ddl.rs | 693 +++-- linux/crates/drivers/mssql/src/dialect.rs | 3 +- linux/crates/drivers/mssql/src/lib.rs | 1 - .../tests/integration/conformance_engine.rs | 27 +- .../mssql/tests/integration/metadata.rs | 17 +- linux/crates/drivers/mysql/src/ddl.rs | 656 +++++ linux/crates/drivers/mysql/src/dialect.rs | 63 +- linux/crates/drivers/mysql/src/lib.rs | 1 + .../tests/integration/conformance_engine.rs | 32 +- linux/crates/drivers/postgres/src/ddl.rs | 656 +++++ linux/crates/drivers/postgres/src/dialect.rs | 38 +- linux/crates/drivers/postgres/src/lib.rs | 1 + .../tests/integration/conformance_engine.rs | 27 +- linux/crates/drivers/sqlite/src/ddl.rs | 567 ++++ linux/crates/drivers/sqlite/src/dialect.rs | 41 +- linux/crates/drivers/sqlite/src/lib.rs | 1 + .../tests/integration/conformance_engine.rs | 25 +- .../test-support/src/conformance/cases/ddl.rs | 643 +++++ .../test-support/src/conformance/cases/mod.rs | 1 + .../src/conformance/ddl_conformance_engine.rs | 30 + .../src/conformance/ddl_suite_macro.rs | 30 + .../test-support/src/conformance/mod.rs | 9 +- linux/po/POTFILES.in | 3 + 72 files changed, 6986 insertions(+), 3885 deletions(-) create mode 100644 linux/crates/app/src/services/pending_structure.rs create mode 100644 linux/crates/app/src/ui/app/sidebar_section_header.rs create mode 100644 linux/crates/app/src/ui/structure_tab/ddl_text.rs create mode 100644 linux/crates/core/src/ddl/build_ddl_error.rs create mode 100644 linux/crates/core/src/ddl/changed_attributes.rs create mode 100644 linux/crates/core/src/ddl/column_alteration.rs create mode 100644 linux/crates/core/src/ddl/column_capabilities.rs create mode 100644 linux/crates/core/src/ddl/column_rename.rs create mode 100644 linux/crates/core/src/ddl/ddl_operation.rs create mode 100644 linux/crates/core/src/ddl/ddl_plan.rs create mode 100644 linux/crates/core/src/ddl/ddl_sql.rs create mode 100644 linux/crates/core/src/ddl/default_text.rs create mode 100644 linux/crates/core/src/ddl/diff_table.rs create mode 100644 linux/crates/core/src/ddl/draft_column.rs create mode 100644 linux/crates/core/src/ddl/draft_problem.rs create mode 100644 linux/crates/core/src/ddl/draft_type.rs create mode 100644 linux/crates/core/src/ddl/drop_table_options.rs create mode 100644 linux/crates/core/src/ddl/permission.rs create mode 100644 linux/crates/core/src/ddl/primary_key_change.rs create mode 100644 linux/crates/core/src/ddl/refusal_reason.rs create mode 100644 linux/crates/core/src/ddl/table_changes.rs create mode 100644 linux/crates/core/src/ddl/table_draft.rs create mode 100644 linux/crates/core/src/ddl/tests.rs delete mode 100644 linux/crates/core/src/sql_ddl.rs create mode 100644 linux/crates/drivers/clickhouse/src/ddl.rs create mode 100644 linux/crates/drivers/mysql/src/ddl.rs create mode 100644 linux/crates/drivers/postgres/src/ddl.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl.rs create mode 100644 linux/crates/test-support/src/conformance/cases/ddl.rs create mode 100644 linux/crates/test-support/src/conformance/ddl_conformance_engine.rs create mode 100644 linux/crates/test-support/src/conformance/ddl_suite_macro.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 5fd77c9a4e..be2fd1172c 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQLite file chooser in the connect dialog, with New Database to create a file. - Results list in the SQL editor with one entry per statement, its rows affected or error, and the open transaction. +### Changed + +- Structure tab offers only the changes the database can make, and says why a control is off. +- Show CREATE TABLE gives the server's own statement on MySQL, ClickHouse and SQLite. + ### Fixed - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. @@ -49,6 +54,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A refresh that failed replaced the rows in a table tab with an error page. - Switching connections could show the previous connection's tables in the sidebar, or its reconnect banner. - Switching connections during a table drop could close the new connection's tabs on a table of the same name. +- Renaming a MySQL table in another database moved it to the connection's database, and renaming a ClickHouse table failed. +- Turning a column's primary key on or off in the Structure tab saved nothing. +- Dropping a SQL Server column that has a default failed. +- Creating a SQLite table with a foreign key failed. ### Security @@ -57,3 +66,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Opening a pipe or a device as a SQL file never finished, or read past the 8 MB limit. - Importing a CSV or a connections file from a pipe or a device never finished, a CSV could read past its size limit, and a connections file had none. - SQL run in the editor, a typed filter or a column default, and the values in them, reached the journal at debug level. +- Column types and defaults typed in the Structure tab reached the SQL after a text filter instead of the database's own parser. diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index 2e0b608607..0285a88923 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -9,6 +9,7 @@ pub mod change_tracker_registry; pub mod from_session; pub mod history_availability; pub mod history_service; +pub mod pending_structure; pub mod secret_labels; pub mod secret_save_report; pub mod session_context; diff --git a/linux/crates/app/src/services/pending_structure.rs b/linux/crates/app/src/services/pending_structure.rs new file mode 100644 index 0000000000..52dfc1bb63 --- /dev/null +++ b/linux/crates/app/src/services/pending_structure.rs @@ -0,0 +1,145 @@ +use tablepro_core::ddl::{BuildDdlError, DdlDialect, DdlPlan, TableChanges, TableDefinition, TableDraft}; +use tablepro_core::meta::TableRef; + +/// What a Structure tab would save: the table as it was read, the +/// user's draft of it and the changes between the two. A new table has +/// no original. +#[derive(Debug, Clone, PartialEq)] +pub struct PendingStructure { + pub original: Option, + pub draft: TableDraft, + pub changes: TableChanges, +} + +impl PendingStructure { + pub fn has_changes(&self) -> bool { + match self.original { + Some(_) => !self.changes.is_empty(), + None => !self.draft.columns.is_empty(), + } + } + + /// The edits the action bar counts. A new table is one. + pub fn change_count(&self) -> usize { + match self.original { + Some(_) => self.changes.change_count(), + None => usize::from(!self.draft.columns.is_empty()), + } + } + + /// Planned with the dialect of the connection that will run it, so + /// the SQL is spelled for that server. + pub fn plan(&self, ddl: &dyn DdlDialect) -> Result { + match &self.original { + Some(original) => ddl.plan_alter_table(original, &self.changes), + None => ddl.plan_create_table(&self.draft), + } + } + + /// The table the write runs against, named as it is before the save. + pub fn table(&self) -> TableRef { + match &self.original { + Some(original) => original.table.clone(), + None => TableRef::new(self.draft.table.schema.clone(), self.draft.table.name.trim()), + } + } + + /// The name the tab follows once the save lands, when that is not + /// the one it already shows: the table a draft created, or the new + /// name of a renamed one. + pub fn saved_table_name(&self) -> Option { + match &self.original { + Some(_) => self.changes.renamed_table.clone(), + None => Some(self.draft.table.name.trim().to_owned()), + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::ServerInfo; + use tablepro_core::ddl::{DraftColumn, DraftType, diff_table}; + + use super::*; + + fn draft() -> TableDraft { + let mut draft = TableDraft::new(TableRef::new(Some("app".to_owned()), " orders ")); + let mut id = DraftColumn::new("id", DraftType::Typed("integer".to_owned())); + id.primary_key = true; + id.nullable = false; + draft.columns.push(id); + draft + } + + fn created() -> PendingStructure { + PendingStructure { + original: None, + draft: draft(), + changes: TableChanges::default(), + } + } + + #[test] + fn pending_structure_plans_with_supplied_dialect() { + let pending = created(); + let postgres = drivers_postgres::PgDialect::new(&ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + let mysql = drivers_mysql::MySqlDialect::new( + drivers_mysql::MySqlFlavour::MySql, + &ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + ); + + let in_postgres = pending.plan(&postgres).expect("the draft plans"); + let in_mysql = pending.plan(&mysql).expect("the draft plans"); + + assert!( + in_postgres.sql()[0].starts_with("CREATE TABLE \"app\".\"orders\""), + "{:?}", + in_postgres.sql() + ); + assert!( + in_mysql.sql()[0].starts_with("CREATE TABLE `app`.`orders`"), + "{:?}", + in_mysql.sql() + ); + assert_eq!(pending.table(), TableRef::new(Some("app".to_owned()), "orders")); + assert_eq!(pending.saved_table_name().as_deref(), Some("orders")); + } + + #[test] + fn an_edit_counts_its_changes_and_follows_a_rename() { + let original = TableDefinition { + table: TableRef::new(Some("app".to_owned()), "orders"), + columns: Vec::new(), + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + }; + let mut renamed = TableDraft::from_definition(&original); + renamed.table.name = "purchases".to_owned(); + let pending = PendingStructure { + changes: diff_table(&original, &renamed), + original: Some(original.clone()), + draft: renamed, + }; + let unchanged = PendingStructure { + changes: TableChanges::default(), + draft: TableDraft::from_definition(&original), + original: Some(original), + }; + + assert!(pending.has_changes()); + assert_eq!(pending.change_count(), 1); + assert_eq!(pending.saved_table_name().as_deref(), Some("purchases")); + assert_eq!(pending.table().name, "orders"); + assert!(!unchanged.has_changes()); + assert!( + !PendingStructure { + draft: TableDraft::new(TableRef::unqualified("t")), + ..created() + } + .has_changes() + ); + } +} diff --git a/linux/crates/app/src/services/structure_tracker.rs b/linux/crates/app/src/services/structure_tracker.rs index e537729c13..d6768d6378 100644 --- a/linux/crates/app/src/services/structure_tracker.rs +++ b/linux/crates/app/src/services/structure_tracker.rs @@ -1,69 +1,76 @@ -//! The DDL a Structure tab would run, cached so the close prompt and -//! the save dispatcher can read it without reaching into the tab. The -//! tab recomputes the ops from its snapshot diff on every edit; DDL has -//! no per-op undo, only Discard back to the snapshot. +//! What a Structure tab would save, kept so the close prompt and the +//! save dispatcher can read it without reaching into the tab. The tab +//! replaces it on every edit; DDL has no per-change undo, only Discard +//! back to the table as it was read. -use tablepro_core::sql_ddl::StructureOp; +use super::pending_structure::PendingStructure; #[derive(Debug, Default)] pub struct StructureChangeTracker { - ops: Vec, + pending: Option, } impl StructureChangeTracker { pub fn has_pending(&self) -> bool { - !self.ops.is_empty() + self.pending.as_ref().is_some_and(PendingStructure::has_changes) } - pub fn ops(&self) -> &[StructureOp] { - &self.ops + pub fn set_pending(&mut self, pending: Option) { + self.pending = pending; } - pub fn set_ops(&mut self, ops: Vec) { - self.ops = ops; + /// What Save writes, or `None` when there is nothing to. + pub fn begin_save(&self) -> Option { + self.pending.clone().filter(PendingStructure::has_changes) } pub fn clear(&mut self) { - self.ops.clear(); + self.pending = None; } } #[cfg(test)] mod tests { + use tablepro_core::ddl::{DraftColumn, DraftType, TableChanges, TableDraft}; + use tablepro_core::meta::TableRef; + use super::*; - use tablepro_core::sql_ddl::DraftColumn; - fn dc(name: &str, ty: &str) -> DraftColumn { - DraftColumn { + fn new_table(columns: usize) -> PendingStructure { + let mut draft = TableDraft::new(TableRef::unqualified("t")); + for index in 0..columns { + draft.columns.push(DraftColumn::new( + format!("c{index}"), + DraftType::Typed("int".to_owned()), + )); + } + PendingStructure { original: None, - name: name.into(), - data_type: ty.into(), - nullable: true, - primary_key: false, - auto_increment: false, - default_value: None, - comment: None, + draft, + changes: TableChanges::default(), } } #[test] fn empty_tracker_has_no_pending() { - let t = StructureChangeTracker::default(); - assert!(!t.has_pending()); - assert_eq!(t.ops().len(), 0); + let tracker = StructureChangeTracker::default(); + + assert!(!tracker.has_pending()); + assert_eq!(tracker.begin_save(), None); } #[test] - fn set_ops_replaces_cache() { - let mut t = StructureChangeTracker::default(); - t.set_ops(vec![StructureOp::AddColumn { - schema: None, - table: "t".into(), - column: dc("a", "int"), - }]); - assert!(t.has_pending()); - assert_eq!(t.ops().len(), 1); - t.set_ops(vec![]); - assert!(!t.has_pending()); + fn only_a_structure_with_changes_is_saved() { + let mut tracker = StructureChangeTracker::default(); + tracker.set_pending(Some(new_table(0))); + assert!(!tracker.has_pending()); + assert_eq!(tracker.begin_save(), None); + + tracker.set_pending(Some(new_table(1))); + assert!(tracker.has_pending()); + assert_eq!(tracker.begin_save(), Some(new_table(1))); + + tracker.clear(); + assert!(!tracker.has_pending()); } } diff --git a/linux/crates/app/src/services/structure_tracker_handle.rs b/linux/crates/app/src/services/structure_tracker_handle.rs index eab37083f9..312cd68c89 100644 --- a/linux/crates/app/src/services/structure_tracker_handle.rs +++ b/linux/crates/app/src/services/structure_tracker_handle.rs @@ -1,22 +1,21 @@ use std::cell::RefCell; use std::rc::Rc; -use tablepro_core::sql_ddl::StructureOp; - +use super::pending_structure::PendingStructure; use super::structure_tracker::StructureChangeTracker; -/// A Structure tab's cached DDL. Every method borrows only for its own +/// A Structure tab's pending save. Every method borrows only for its own /// body and returns owned data. #[derive(Debug, Clone, Default)] pub struct StructureTrackerHandle(Rc>); impl StructureTrackerHandle { - pub fn set_ops(&self, ops: Vec) { - self.0.borrow_mut().set_ops(ops); + pub fn set_pending(&self, pending: Option) { + self.0.borrow_mut().set_pending(pending); } - pub fn ops(&self) -> Vec { - self.0.borrow().ops().to_vec() + pub fn begin_save(&self) -> Option { + self.0.borrow().begin_save() } pub fn has_pending(&self) -> bool { diff --git a/linux/crates/app/src/services/structure_tracker_registry.rs b/linux/crates/app/src/services/structure_tracker_registry.rs index 45b9122ab4..0753c22aee 100644 --- a/linux/crates/app/src/services/structure_tracker_registry.rs +++ b/linux/crates/app/src/services/structure_tracker_registry.rs @@ -11,26 +11,23 @@ impl TrackerHandle for StructureTrackerHandle { #[cfg(test)] mod tests { - use tablepro_core::sql_ddl::{DraftColumn, StructureOp}; + use tablepro_core::ddl::{DraftColumn, DraftType, TableChanges, TableDraft}; + use tablepro_core::meta::TableRef; use uuid::Uuid; use super::*; - - fn add_column() -> StructureOp { - StructureOp::AddColumn { - schema: None, - table: "t".into(), - column: DraftColumn { - original: None, - name: "a".into(), - data_type: "int".into(), - nullable: true, - primary_key: false, - auto_increment: false, - default_value: None, - comment: None, - }, - } + use crate::services::pending_structure::PendingStructure; + + fn new_table() -> Option { + let mut draft = TableDraft::new(TableRef::unqualified("t")); + draft + .columns + .push(DraftColumn::new("a", DraftType::Typed("int".to_owned()))); + Some(PendingStructure { + original: None, + draft, + changes: TableChanges::default(), + }) } #[test] @@ -39,7 +36,7 @@ mod tests { let second = StructureTrackerRegistry::default(); let tab = Uuid::new_v4(); - first.register(tab).set_ops(vec![add_column()]); + first.register(tab).set_pending(new_table()); second.register(tab); assert_eq!(first.dirty(), vec![tab]); @@ -50,7 +47,7 @@ mod tests { fn dirty_lists_only_pending_tabs() { let registry = StructureTrackerRegistry::default(); let edited = Uuid::new_v4(); - registry.register(edited).set_ops(vec![add_column()]); + registry.register(edited).set_pending(new_table()); registry.register(Uuid::new_v4()); assert_eq!(registry.dirty(), vec![edited]); diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 39034f3e66..733edd4cb0 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -3,6 +3,7 @@ mod connection; mod import; mod ran_statements; mod row_ops; +mod sidebar_section_header; mod status_pages; mod structure; mod teardown_guard; @@ -486,10 +487,9 @@ pub enum AppMsg { schema: Option, table: String, }, - /// Sidebar right-click → "Show CREATE TABLE". App fetches - /// columns, indexes, and FKs, synthesises a CreateTable op, - /// materialises through `sql_ddl::materialize_ops`, and opens - /// the resulting SQL in a fresh editor tab. + /// Sidebar right-click → "Show CREATE TABLE". The table's + /// definition goes through the dialect's `create_table_script`, and + /// the SQL opens in a fresh editor tab. ShowCreateTableForExisting { schema: Option, table: String, @@ -511,13 +511,6 @@ pub enum AppMsg { }, /// The table is gone, so its tabs close without asking. DropTableSucceeded(FromSession), - /// Structure tab Save: run the materialised DDL statements - /// sequentially. Postgres wraps in BEGIN / COMMIT for atomicity; - /// MySQL / SQLite execute per-statement (DDL implicitly commits). - ExecuteStructureTransaction { - tab_id: Uuid, - statements: Vec, - }, /// Save one Structure tab, which need not be the active one. SaveActiveStructureTabById(Uuid), /// Structure tab Save resolved successfully. `new_table_name` is @@ -564,19 +557,6 @@ impl App { pub(super) fn dirty_tabs(&self) -> Vec { dirty_tabs(&self.change_trackers, &self.structure_trackers) } - - /// The active driver id, or "postgres" if no connection is active. - /// The fallback would quote SQL wrongly for another engine, so it is - /// logged. - pub(super) fn driver_id(&self) -> &str { - match self.current_driver_id.as_deref() { - Some(id) => id, - None => { - tracing::warn!("driver_id called without active connection; falling back to postgres"); - "postgres" - } - } - } } #[relm4::component(pub)] @@ -846,11 +826,8 @@ impl SimpleComponent for App { window_for_resp.close(); } "save" => { - // Commit each dirty tab. Browse tabs go through - // SaveActiveBrowseTabById; Structure tabs need - // ExecuteStructureTransaction with materialized - // statements. close_after_save tracks both kinds; - // the SaveCompletedForTab / StructureSaveCompleted + // close_after_save tracks both kinds of save; the + // SaveCompletedForTab / StructureSaveCompleted // handlers in App::update close the window once // the set drains. Any SaveFailed aborts. let browse_tabs = change_trackers_for_resp.dirty(); @@ -1021,6 +998,7 @@ impl SimpleComponent for App { let schemas_for_header = sidebar_schemas.clone(); let sender_for_header = sender.clone(); + let context_for_header = context.clone(); sidebar_listbox.set_header_func(move |row, before| { let schemas = schemas_for_header.borrow(); let total_distinct: std::collections::BTreeSet<&str> = @@ -1042,49 +1020,20 @@ impl SimpleComponent for App { row.set_header(gtk::Widget::NONE); return; } - let header_box = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(6) - .margin_top(12) - .margin_bottom(6) - .margin_start(12) - // Lines the "+" up with the end of the row content. - .margin_end(12) - .build(); - let label_text = current - .as_deref() - .map(|s| s.to_string()) - .unwrap_or_else(|| crate::i18n::gettext("Tables")); - let label = gtk::Label::builder() - .label(&label_text) - .xalign(0.0) - .hexpand(true) - .build(); - // GtkPlacesSidebar's section header: small, bold and dim. - label.add_css_class("caption-heading"); - label.add_css_class("dim-label"); - header_box.append(&label); - // "+" button: emit NewTableTab carrying this schema. Flat - // styling matches GNOME Files' inline-add buttons; the - // tooltip clarifies the destination ("New Table in …") - // so the user understands what the schema scoping means. - let new_table_button = gtk::Button::builder() - .icon_name(crate::ui::icons::LIST_ADD) - .tooltip_text(match current.as_deref() { - Some(s) => crate::i18n::gettext_f("New Table in {schema}…", &[("schema", s)]), - None => crate::i18n::gettext("New Table…"), - }) - .valign(gtk::Align::Center) - .build(); - new_table_button.add_css_class("flat"); + let can_create_table = context_for_header + .active + .get() + .and_then(|session| session.current_dialect()) + .and_then(|dialect| dialect.ddl().map(|ddl| ddl.capabilities().create_table)) + .unwrap_or(false); let sender_for_button = sender_for_header.clone(); let schema_for_button = current.clone(); - new_table_button.connect_clicked(move |_| { - sender_for_button.input(AppMsg::NewTableTab { - schema: schema_for_button.clone(), + let header_box = + sidebar_section_header::sidebar_section_header(current.as_deref(), can_create_table, move || { + sender_for_button.input(AppMsg::NewTableTab { + schema: schema_for_button.clone(), + }); }); - }); - header_box.append(&new_table_button); row.set_header(Some(&header_box)); }); @@ -1399,9 +1348,6 @@ impl SimpleComponent for App { self.on_drop_table_succeeded(table, sender); } } - AppMsg::ExecuteStructureTransaction { tab_id, statements } => { - self.on_execute_structure_transaction(tab_id, statements, sender) - } AppMsg::SaveActiveStructureTabById(id) => self.save_structure_tab_by_id(id, sender), AppMsg::StructureSaveCompleted { tab_id, new_table_name } => { self.on_structure_save_completed(tab_id, new_table_name, sender) diff --git a/linux/crates/app/src/ui/app/sidebar_section_header.rs b/linux/crates/app/src/ui/app/sidebar_section_header.rs new file mode 100644 index 0000000000..9690084a7a --- /dev/null +++ b/linux/crates/app/src/ui/app/sidebar_section_header.rs @@ -0,0 +1,69 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +/// A schema's heading in the sidebar, with its "+" button for a new +/// table where the connection's DDL dialect creates tables. ClickHouse +/// does not: a table there needs an engine and a sorting key the +/// structure tab has no place for, so the button would only fail. +pub(super) fn sidebar_section_header( + schema: Option<&str>, + can_create_table: bool, + on_new_table: impl Fn() + 'static, +) -> gtk::Box { + let header = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .margin_top(12) + .margin_bottom(6) + .margin_start(12) + // Lines the "+" up with the end of the row content. + .margin_end(12) + .build(); + let label_text = schema + .map(str::to_owned) + .unwrap_or_else(|| crate::i18n::gettext("Tables")); + let label = gtk::Label::builder() + .label(&label_text) + .xalign(0.0) + .hexpand(true) + .build(); + // GtkPlacesSidebar's section header: small, bold and dim. + label.add_css_class("caption-heading"); + label.add_css_class("dim-label"); + header.append(&label); + if !can_create_table { + return header; + } + // The tooltip says which schema the table goes in. + let new_table_button = gtk::Button::builder() + .icon_name(crate::ui::icons::LIST_ADD) + .tooltip_text(match schema { + Some(schema) => crate::i18n::gettext_f("New Table in {schema}…", &[("schema", schema)]), + None => crate::i18n::gettext("New Table…"), + }) + .valign(gtk::Align::Center) + .build(); + new_table_button.add_css_class("flat"); + new_table_button.connect_clicked(move |_| on_new_table()); + header.append(&new_table_button); + header +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::first_descendant_of_type; + + #[gtk4::test] + fn new_table_button_hidden_without_create_table_capability() { + let with = sidebar_section_header(Some("public"), true, || {}); + let without = sidebar_section_header(Some("analytics"), false, || {}); + + let button = first_descendant_of_type::(&with).expect("the new table button"); + assert_eq!(button.tooltip_text().as_deref(), Some("New Table in public…")); + assert!( + first_descendant_of_type::(&without).is_none(), + "a connection that cannot create tables offers to" + ); + } +} diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index 76d4272012..ea7bda9423 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -14,16 +14,16 @@ use relm4::adw::prelude::*; use relm4::{ComponentSender, adw}; use uuid::Uuid; -use tablepro_core::ddl::TableDefinition; +use tablepro_core::ddl::{BuildDdlError, DdlDialect, DdlOperation, DdlPlan, DropTableOptions, TableDefinition}; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{ObjectScope, TableRef}; -use tablepro_core::sql_ddl::{BuildDdlError, DraftColumn, StructureOp, materialize_ops}; -use tablepro_core::statement::{GuardedStatement, StatementBuilder}; -use tablepro_core::{ForeignKeyMode, WriteBatch, WriteKind, WriteReport}; +use tablepro_core::statement::SqlBuildError; +use tablepro_core::{WriteKind, WriteReport}; use tablepro_session::runtime::TaskFailure; use tablepro_session::{OperationError, OperationKind, Session}; use crate::services::from_session::FromSession; +use crate::services::pending_structure::PendingStructure; use crate::ui::app::{App, AppMsg, WorkspaceTab}; use crate::ui::error_text; use crate::ui::structure_tab::{StructureMode, StructureTabInput}; @@ -35,6 +35,19 @@ impl App { self.show_toast(&crate::i18n::gettext("Connect to a database first.")); return; } + let creates_tables = self + .context + .active + .get() + .and_then(|session| session.current_dialect()) + .and_then(|dialect| dialect.ddl().map(|ddl| ddl.capabilities().create_table)) + .unwrap_or(false); + if !creates_tables { + self.show_toast(&crate::i18n::gettext( + "Tables on this database are created in the SQL editor.", + )); + return; + } self.append_new_structure_tab(schema, sender); } @@ -110,16 +123,6 @@ impl App { table: String, sender: ComponentSender, ) { - let Some(driver_id) = self.current_driver_id.clone() else { - return; - }; - let sql = match tablepro_core::sql_ddl::build_drop_table(&driver_id, schema.as_deref(), &table, true, false) { - Ok(s) => s, - Err(e) => { - self.show_error_alert(&crate::i18n::gettext("Cannot drop table"), &format!("{e}")); - return; - } - }; let Some(session) = self.context.active.get() else { self.show_error_alert( &crate::i18n::gettext("Cannot drop table"), @@ -128,10 +131,16 @@ impl App { return; }; let dropped = TableRef::new(schema, table); - let batch = schema_write(dropped.clone(), &[sql]); + let options = DropTableOptions { + if_exists: true, + cascade: false, + }; let origin = Arc::downgrade(&session); - crate::runtime::spawn_and_deliver(&self.context.tasks, run_schema_write(session, batch), move |result| { - match settled(result) { + let work = run_schema_plan(session, dropped.clone(), move |ddl, table| { + ddl.plan_drop_table(table, options) + }); + crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { + match settled(result).and_then(|(_, written)| written) { Ok(_) => sender.input(AppMsg::DropTableSucceeded(FromSession::new(origin, dropped))), Err(error) => sender.input(AppMsg::ShowAlert { title: crate::i18n::gettext("Drop failed"), @@ -141,8 +150,8 @@ impl App { }); } - /// Save one Structure tab's DDL, whether or not it is the active - /// tab. + /// Save one Structure tab, whether or not it is the active tab: what + /// its tracker holds, planned with the connection that runs it. pub(super) fn save_structure_tab_by_id(&mut self, tab_id: Uuid, sender: ComponentSender) { // The save in flight settles `close_after_save` for this tab; // a second one would return without ever completing. @@ -153,27 +162,17 @@ impl App { ); return; } - let Some(dialect) = self.context.active.get().and_then(|session| session.current_dialect()) else { - sender.input(AppMsg::StructureSaveFailed( - tab_id, - crate::i18n::gettext("No active connection."), - )); - return; - }; let Some(tracker) = self.structure_trackers.get(tab_id) else { return; }; - match materialize_ops(&tracker.ops(), self.driver_id(), &*dialect) { - Ok(statements) if !statements.is_empty() => { - self.on_execute_structure_transaction(tab_id, statements, sender); - } + match tracker.begin_save() { + Some(pending) => self.run_structure_save(tab_id, pending, sender), // Nothing to save, so a close waiting on this save can go // ahead. - Ok(_) => sender.input(AppMsg::StructureSaveCompleted { + None => sender.input(AppMsg::StructureSaveCompleted { tab_id, new_table_name: None, }), - Err(e) => sender.input(AppMsg::StructureSaveFailed(tab_id, format!("{e}"))), } } @@ -205,20 +204,14 @@ impl App { } } - /// Structure tab Save → run the ordered DDL as one schema write. - /// The driver decides how it is wrapped: a transaction where the - /// engine's DDL is transactional, statement by statement where it - /// is not, and the error says which of the two the user got. - pub(super) fn on_execute_structure_transaction( - &mut self, - tab_id: Uuid, - statements: Vec, - sender: ComponentSender, - ) { - // Reject re-entry: a second Ctrl+S (or rapid double-click on - // Save) while the first DDL write is still mid-flight would - // dispatch a parallel write and potentially commit the same - // statements twice. Mark the tab and bail. + /// Run a structure save as one schema write. The driver decides how + /// it is wrapped: a transaction where the engine's DDL is + /// transactional, statement by statement where it is not, and the + /// error says which of the two the user got. + fn run_structure_save(&mut self, tab_id: Uuid, pending: PendingStructure, sender: ComponentSender) { + // A second Ctrl+S, or a quick double-click on Save, while the + // first write is still running would commit the same statements + // twice. if !self.structure_saves_in_flight.borrow_mut().insert(tab_id) { tracing::debug!(?tab_id, "structure save already in flight; ignoring duplicate"); return; @@ -231,50 +224,34 @@ impl App { )); return; }; - let (mode, schema, slot_table) = { - let tabs = self.workspace_tabs.borrow(); - match tabs.get(&tab_id) { - Some(WorkspaceTab::Structure(s)) => (s.mode, s.schema.clone(), s.table.clone()), - // Only a Structure tab hosts the DDL editor, so a save - // for any other id is a stale queued message. - _ => { - self.structure_saves_in_flight.borrow_mut().remove(&tab_id); - return; - } - } - }; - let ops = self - .structure_trackers - .get(tab_id) - .map(|tracker| tracker.ops()) - .unwrap_or_default(); - let new_table_name = saved_table_name(mode, &ops); - // The batch names the table as it is before the write: a new - // table by the name it is being created under. - let batch_table = match mode { - StructureMode::New => new_table_name.clone().unwrap_or_default(), - StructureMode::Edit => slot_table, - }; - let batch = schema_write(TableRef::new(schema, batch_table), &statements); + // Only a Structure tab hosts the DDL editor, so a save for any + // other id is a stale queued message. + if !matches!( + self.workspace_tabs.borrow().get(&tab_id), + Some(WorkspaceTab::Structure(_)) + ) { + self.structure_saves_in_flight.borrow_mut().remove(&tab_id); + return; + } + let new_table_name = pending.saved_table_name(); self.in_flight_saves.set(self.in_flight_saves.get() + 1); - // Structure edits change the database too, so the history - // shows the DDL that did it and not only editor statements. - let ran = super::ran_statements::RanStatements::starting( - self.history.store(), - self.context.active.metadata(), - statements, - ); + let history = self.history.store(); + let metadata = self.context.active.metadata(); let work = async move { - let result = run_schema_write(session, batch).await; - let outcome = match &result { + let table = pending.table(); + let (statements, written) = run_schema_plan(session, table, move |ddl, _| pending.plan(ddl)).await?; + // Structure edits change the database too, so the history + // shows the DDL that did it and not only editor statements. + let ran = super::ran_statements::RanStatements::starting(history, metadata, statements); + let outcome = match &written { Ok(_) => tablepro_storage::query_history::Outcome::Success, Err(error) => { tablepro_storage::query_history::Outcome::Error(error_text::operation_error_message(error)) } }; ran.finished(None, outcome).await; - result + written }; crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| match settled(result) { Ok(_) => sender.input(AppMsg::StructureSaveCompleted { tab_id, new_table_name }), @@ -391,29 +368,29 @@ impl App { self.send_to_structure_tab(tab_id, StructureTabInput::SaveFailed(message)); } - /// Sidebar "Show CREATE TABLE": synthesise the canonical CREATE - /// statement for an existing table from its definition, through the - /// same `materialize_ops` the Structure tab saves with. The result - /// lands in a fresh editor tab; no Structure tab is touched. + /// Sidebar "Show CREATE TABLE": the statements that recreate the + /// table as it is defined now, from the dialect's + /// `create_table_script`. The result lands in a fresh editor tab; no + /// Structure tab is touched. pub(super) fn on_show_create_table(&self, schema: Option, table: String, sender: ComponentSender) { let Some(session) = self.context.active.get() else { self.show_toast(&crate::i18n::gettext("No active connection.")); return; }; - let driver_id = self.driver_id().to_string(); let origin = Arc::downgrade(&session); let work = read_definition(session, TableRef::new(schema, table.clone())); crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { let message = match settled(result) { - Ok((definition, dialect)) => match create_table_sql(&definition, &driver_id, &*dialect) { + Ok((definition, dialect)) => match create_table_sql(&definition, &*dialect) { Ok(Some(sql)) => { sender.input(AppMsg::ShowCreateTableLoaded(FromSession::new(origin, sql))); return; } Ok(None) => crate::i18n::gettext_f("Table {table} has no columns.", &[("table", &table)]), - Err(error) => { - crate::i18n::gettext_f("Couldn't build SQL: {error}", &[("error", &error.to_string())]) - } + Err(error) => crate::i18n::gettext_f( + "Couldn't build SQL: {error}", + &[("error", &crate::ui::structure_tab::build_error_text(&error))], + ), }, Err(error) => crate::i18n::gettext_f( "Couldn't read the table: {error}", @@ -515,36 +492,33 @@ impl App { } } -/// The table name a save leaves the tab on, when it is not the one the -/// tab already shows: the created table for a draft, the new name for -/// a rename. -fn saved_table_name(mode: StructureMode, ops: &[StructureOp]) -> Option { - ops.iter().find_map(|op| match (mode, op) { - (StructureMode::New, StructureOp::CreateTable { table, .. }) => Some(table.clone()), - (StructureMode::Edit, StructureOp::RenameTable { new_name, .. }) => Some(new_name.clone()), - _ => None, - }) -} - -/// DDL as one schema write. Each statement is the builder's own text -/// with nothing bound, and none of them has a row count to prove. -fn schema_write(table: TableRef, statements: &[String]) -> WriteBatch { - let steps = statements - .iter() - .map(|sql| { - let mut builder = StatementBuilder::new(); - builder.push_sql(sql); - GuardedStatement::unguarded(builder.finish()) - }) - .collect(); - WriteBatch::new(WriteKind::SchemaChanges, table, steps, ForeignKeyMode::Enforced) -} - -async fn run_schema_write(session: Arc, batch: WriteBatch) -> Result { +/// Plan a schema change with the dialect of the connection that runs +/// it, and run the plan as one write. The statements come back whether +/// or not the write went through, for the history. +async fn run_schema_plan

( + session: Arc, + table: TableRef, + plan: P, +) -> Result<(Vec, Result), OperationError> +where + P: Fn(&dyn DdlDialect, &TableRef) -> Result + Send + Sync, +{ session .run(OperationKind::Save(WriteKind::SchemaChanges), move |lease, options| { - let batch = batch.clone(); - async move { Ok(lease.write_batch(&batch, options).await?) } + let table = table.clone(); + let dialect = lease.dialect(); + let planned = match dialect.ddl() { + Some(ddl) => plan(ddl, &table).map_err(SqlBuildError::from), + None => Err(SqlBuildError::from(BuildDdlError::Unsupported( + DdlOperation::CreateTable, + ))), + }; + async move { + let plan = planned?; + let statements: Vec = plan.sql().into_iter().map(str::to_owned).collect(); + let written = lease.write_batch(&plan.into_write_batch(table), options).await; + Ok((statements, written.map_err(OperationError::from))) + } }) .await } @@ -569,28 +543,15 @@ async fn read_definition( /// The CREATE statements for a table as it is defined now, joined so /// the editor shows each on its own. `None` for a table with no /// columns, which has nothing to create. -fn create_table_sql( - definition: &TableDefinition, - driver_id: &str, - dialect: &dyn SqlDialect, -) -> Result, BuildDdlError> { +fn create_table_sql(definition: &TableDefinition, dialect: &dyn SqlDialect) -> Result, BuildDdlError> { if definition.columns.is_empty() { return Ok(None); } - let op = StructureOp::CreateTable { - schema: definition.table.schema.clone(), - table: definition.table.name.clone(), - columns: definition - .columns - .iter() - .cloned() - .map(DraftColumn::from_definition) - .collect(), - indexes: definition.indexes.clone(), - fks: definition.foreign_keys.clone(), - }; - let statements = materialize_ops(&[op], driver_id, dialect)?; - Ok((!statements.is_empty()).then(|| statements.join(";\n\n") + ";")) + let ddl = dialect + .ddl() + .ok_or(BuildDdlError::Unsupported(DdlOperation::CreateTable))?; + let script = ddl.create_table_script(definition)?; + Ok((!script.is_empty()).then(|| script.preview())) } /// A task that never answered is reported like the operation it ran: a @@ -609,24 +570,11 @@ mod tests { use tablepro_core::column::{ CatalogType, ColumnDefault, ColumnKind, ColumnType, ReadForm, SqlTypeExpr, TextLength, }; - use tablepro_core::ddl::{ColumnAttributes, ColumnDefinition}; + use tablepro_core::ddl::{ColumnAttributes, ColumnDefinition, TableSource}; use tablepro_core::{ColumnInfo, ForeignKeyInfo}; use super::*; - fn draft(name: &str) -> DraftColumn { - DraftColumn { - original: None, - name: name.to_owned(), - data_type: "text".to_owned(), - nullable: true, - primary_key: false, - auto_increment: false, - default_value: None, - comment: None, - } - } - #[expect( clippy::disallowed_methods, reason = "a test stands in for the catalogue a driver would read" @@ -670,57 +618,6 @@ mod tests { } } - #[test] - fn a_draft_saves_under_the_name_it_creates() { - let ops = [StructureOp::CreateTable { - schema: None, - table: "orders".to_owned(), - columns: vec![draft("id")], - indexes: Vec::new(), - fks: Vec::new(), - }]; - - assert_eq!(saved_table_name(StructureMode::New, &ops).as_deref(), Some("orders")); - assert_eq!(saved_table_name(StructureMode::Edit, &ops), None); - } - - #[test] - fn an_edit_that_renames_the_table_moves_the_tab_to_the_new_name() { - let ops = [ - StructureOp::RenameTable { - schema: None, - old_name: "users".to_owned(), - new_name: "accounts".to_owned(), - }, - StructureOp::AddColumn { - schema: None, - table: "accounts".to_owned(), - column: draft("email"), - }, - ]; - - assert_eq!(saved_table_name(StructureMode::Edit, &ops).as_deref(), Some("accounts")); - assert_eq!(saved_table_name(StructureMode::Edit, &ops[1..]), None); - } - - #[test] - fn a_schema_write_carries_each_statement_unguarded_and_unbound() { - let table = TableRef::new(None, "users"); - let statements = [ - "ALTER TABLE users ADD COLUMN a text".to_owned(), - "DROP INDEX i".to_owned(), - ]; - - let batch = schema_write(table.clone(), &statements); - - assert_eq!(batch.kind, WriteKind::SchemaChanges); - assert_eq!(batch.table, table); - assert!(batch.foreign_keys.is_enforced()); - let sql: Vec<&str> = batch.steps.iter().map(|step| step.statement.sql()).collect(); - assert_eq!(sql, ["ALTER TABLE users ADD COLUMN a text", "DROP INDEX i"]); - assert!(batch.steps.iter().all(|step| step.statement.params().is_empty())); - } - fn postgres_dialect() -> drivers_postgres::PgDialect { drivers_postgres::PgDialect::new(&tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)) } @@ -729,23 +626,37 @@ mod tests { fn create_table_sql_restates_the_definition() { let dialect = postgres_dialect(); - let sql = create_table_sql(&definition(vec![text_column("id")]), "postgres", &dialect) + let sql = create_table_sql(&definition(vec![text_column("id")]), &dialect) .expect("the SQL builds") .expect("a table with columns"); - assert!(sql.starts_with("CREATE TABLE"), "{sql}"); - assert!(sql.contains("\"users\""), "{sql}"); + assert!(sql.starts_with("CREATE TABLE \"public\".\"users\""), "{sql}"); assert!(sql.ends_with(';'), "{sql}"); } + #[test] + fn create_table_sql_is_the_servers_own_text_where_it_keeps_one() { + let dialect = drivers_mysql::MySqlDialect::new( + drivers_mysql::MySqlFlavour::MySql, + &tablepro_core::ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + ); + let mut table = definition(vec![text_column("id")]); + table.source = Some(TableSource { + create_statement: "CREATE TABLE `users` (`id` text) ENGINE=InnoDB".to_owned(), + dependents: Vec::new(), + }); + + assert_eq!( + create_table_sql(&table, &dialect), + Ok(Some("CREATE TABLE `users` (`id` text) ENGINE=InnoDB;".to_owned())) + ); + } + #[test] fn a_table_without_columns_has_nothing_to_create() { let dialect = postgres_dialect(); - assert!(matches!( - create_table_sql(&definition(Vec::new()), "postgres", &dialect), - Ok(None) - )); + assert!(matches!(create_table_sql(&definition(Vec::new()), &dialect), Ok(None))); } #[test] diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index 7f703c5e36..3f8ce317b4 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -178,7 +178,6 @@ impl App { return; }; let tab_id = Uuid::new_v4(); - let driver_id = self.driver_id().to_string(); let Some(session) = self.context.active.get() else { return; }; @@ -187,7 +186,6 @@ impl App { schema: schema.clone(), table: String::new(), mode: StructureMode::New, - driver_id, session, tasks: self.context.tasks.clone(), }); @@ -299,7 +297,6 @@ impl App { return; }; let tab_id = Uuid::new_v4(); - let driver_id = self.driver_id().to_string(); let Some(session) = self.context.active.get() else { return; }; @@ -309,7 +306,6 @@ impl App { schema: schema.clone(), table: table.clone(), mode, - driver_id, session, tasks: self.context.tasks.clone(), }); @@ -1367,10 +1363,8 @@ fn editor_tab_tooltip(query: &str, label: &str) -> Option { fn connect_structure(structure: &StructureTab, tab_id: Uuid, input: &relm4::Sender) { let dirty = input.clone(); structure.connect_dirty_changed(move |_, is_dirty| dirty.emit(AppMsg::StructureTabDirtyChanged(tab_id, is_dirty))); - let execute = input.clone(); - structure.connect_execute(move |_, statements| { - execute.emit(AppMsg::ExecuteStructureTransaction { tab_id, statements }); - }); + let save = input.clone(); + structure.connect_save_requested(move |_| save.emit(AppMsg::SaveActiveStructureTabById(tab_id))); let drop_table = input.clone(); structure.connect_drop_table_requested(move |_, schema, table| { drop_table.emit(AppMsg::DropTablePrompt { schema, table }); diff --git a/linux/crates/app/src/ui/structure_tab/columns.rs b/linux/crates/app/src/ui/structure_tab/columns.rs index de76a381da..7411191956 100644 --- a/linux/crates/app/src/ui/structure_tab/columns.rs +++ b/linux/crates/app/src/ui/structure_tab/columns.rs @@ -1,127 +1,39 @@ -//! Column-row builder + per-driver type helpers used by the Columns -//! page of the Structure tab. +//! The column rows of the Structure tab's Columns page. use std::cell::{Cell, RefCell}; use std::rc::Rc; +use std::sync::Arc; use gtk4 as gtk; use gtk4::gio; use libadwaita as adw; use libadwaita::prelude::*; +use tablepro_core::ddl::{ColumnCapabilities, DraftColumn, DraftType, Permission}; +use tablepro_core::dialect::SqlDialect; -use tablepro_core::sql_ddl::DraftColumn; - +use super::ddl_text::refusal_text; use super::{ColumnField, StructureTabInput, StructureTabSender}; -/// The type suggestions per driver, most common first. Any other type -/// can still be typed. -pub(super) fn driver_types(driver_id: &str) -> &'static [&'static str] { - match driver_id { - "postgres" => &[ - "integer", - "bigint", - "smallint", - "text", - "varchar(255)", - "boolean", - "timestamp", - "timestamp with time zone", - "date", - "time", - "numeric(10, 2)", - "real", - "double precision", - "uuid", - "jsonb", - "json", - "bytea", - "serial", - "bigserial", - ], - "mysql" => &[ - "INT", - "BIGINT", - "SMALLINT", - "TINYINT", - "VARCHAR(255)", - "TEXT", - "BOOLEAN", - "DATETIME", - "TIMESTAMP", - "DATE", - "TIME", - "DECIMAL(10, 2)", - "FLOAT", - "DOUBLE", - "JSON", - "BLOB", - "CHAR(36)", - ], - "sqlite" => &[ - "INTEGER", "TEXT", "REAL", "BLOB", "NUMERIC", "BOOLEAN", "DATETIME", "DATE", - ], - "mssql" => &[ - "int", - "bigint", - "smallint", - "tinyint", - "bit", - "nvarchar(255)", - "nvarchar(max)", - "varchar(255)", - "decimal(18, 2)", - "float", - "real", - "money", - "date", - "time", - "datetime2", - "datetimeoffset", - "uniqueidentifier", - "varbinary(max)", - ], - _ => &["TEXT"], - } -} - -/// Whether the driver supports column-level ALTER on an existing -/// column (type / nullable / default). SQLite blocks all three; the -/// UI uses this to grey out non-supported cells with explanatory -/// tooltips. -fn driver_can_alter_existing_column(driver_id: &str) -> bool { - !matches!(driver_id, "sqlite") -} - -/// Whether the engine has somewhere to keep a column description. -/// SQLite does not: a comment in the CREATE TABLE text is thrown away -/// by the parser, so the field is shown but cannot be edited. -fn driver_stores_column_comments(driver_id: &str) -> bool { - !matches!(driver_id, "sqlite") -} - -fn driver_can_drop_column(_driver_id: &str) -> bool { - // SQLite ≥ 3.35 supports DROP COLUMN; the builder doesn't probe - // the runtime version. Always enable; the driver surfaces the - // error if running against an older SQLite. - true -} - -pub(super) fn default_type_for(driver_id: &str) -> String { - match driver_id { - "postgres" => "text".into(), - "mysql" => "VARCHAR(255)".into(), - "sqlite" => "TEXT".into(), - "mssql" => "nvarchar(255)".into(), - _ => "TEXT".into(), - } +/// What a column row needs beyond the column: which changes the engine +/// allows on it, and the dialect that checks what the user types. +pub(super) struct ColumnRowContext { + pub permissions: ColumnCapabilities, + pub type_suggestions: &'static [&'static str], + pub column_comments: bool, + pub dialect: Option>, + /// The default as SQL the user could type back in, or the text they + /// typed that did not parse. + pub default_text: String, + pub default_error: Option, } /// The collapsed row's summary, such as `varchar(255) · NOT NULL · /// Primary key`. fn format_column_subtitle(col: &DraftColumn) -> String { let mut parts: Vec = Vec::new(); - if !col.data_type.trim().is_empty() { - parts.push(col.data_type.clone()); + let type_text = col.column_type.text(); + if !type_text.is_empty() { + parts.push(type_text.to_owned()); } parts.push(if col.nullable { crate::i18n::gettext("nullable") @@ -137,23 +49,59 @@ fn format_column_subtitle(col: &DraftColumn) -> String { parts.join(" · ") } -/// A field SQLite cannot alter is insensitive, with a tooltip saying -/// why; a new column is fully editable, since SQLite accepts it as an -/// `ADD COLUMN`. -/// +/// Turn a control off with the reason, in its tooltip and for screen +/// readers, when the engine refuses the change it makes. +pub(super) fn gate(widget: &impl IsA, permission: &Permission) { + if let Permission::Refused(reason) = permission { + let text = refusal_text(reason); + widget.set_sensitive(false); + widget.set_tooltip_text(Some(&text)); + widget + .upcast_ref::() + .update_property(&[gtk::accessible::Property::Description(&text)]); + } +} + +/// Mark a field whose text the engine's parser refused, or clear it. +fn mark_invalid(row: &adw::EntryRow, error: Option<&str>) { + match error { + Some(message) => { + row.add_css_class("error"); + row.set_tooltip_text(Some(message)); + row.upcast_ref::() + .update_property(&[gtk::accessible::Property::Description(message)]); + } + None => { + row.remove_css_class("error"); + row.set_tooltip_text(None); + row.upcast_ref::() + .reset_property(gtk::AccessibleProperty::Description); + } + } +} + +/// Whether the engine's parser takes `text` as one type. A catalogue +/// declaration left as it was is never parsed again. +fn type_error(dialect: Option<&dyn SqlDialect>, column: &DraftColumn, text: &str) -> Option { + let unchanged = matches!(&column.column_type, DraftType::Catalog(_)) && column.column_type.text() == text; + if unchanged { + return None; + } + let dialect = dialect?; + dialect.parse_type(text).err().map(|error| error.to_string()) +} + /// While `suppress_emit` is set, the signals GTK fires as the initial /// values go in are not taken for edits. pub(super) fn build_column_expander_row( index: usize, col: &DraftColumn, - driver_id: &str, + context: ColumnRowContext, sender: StructureTabSender, suppress_emit: Rc>, popover_registry: Rc>>, ) -> adw::ExpanderRow { - let is_existing = col.original.is_some(); - let limit_for_existing = is_existing && driver_id == "sqlite"; - + let permissions = &context.permissions; let row = adw::ExpanderRow::builder() .title(glib::markup_escape_text(&col.name)) .subtitle(glib::markup_escape_text(&format_column_subtitle(col))) @@ -167,18 +115,18 @@ pub(super) fn build_column_expander_row( .valign(gtk::Align::Center) .build(); remove_button.add_css_class("flat"); - if is_existing && !driver_can_drop_column(driver_id) { - remove_button.set_sensitive(false); - } + remove_button.set_widget_name(&format!("col-remove-{index}")); + gate(&remove_button, &permissions.drop); let sender_for_remove = sender.clone(); remove_button.connect_clicked(move |_| sender_for_remove.input(StructureTabInput::RemoveColumn(index))); row.add_suffix(&remove_button); - // Name (AdwEntryRow). The expander's title mirrors this entry - // live so the collapsed header always reflects the user's input. + // The expander's title mirrors this entry live, so the collapsed + // header always shows what the user typed. let name_row = adw::EntryRow::builder().title(crate::i18n::gettext("Name")).build(); name_row.set_text(&col.name); name_row.set_widget_name(&format!("col-name-{index}")); + gate(&name_row, &permissions.rename); let sender_for_name = sender.clone(); let suppress_for_name = suppress_emit.clone(); // The row owns this entry, so the entry holds the row weakly. @@ -201,38 +149,45 @@ pub(super) fn build_column_expander_row( // Free text, so `decimal(10,2)` or an enum type need no list; the // menu only suggests. let type_row = adw::EntryRow::builder().title(crate::i18n::gettext("Type")).build(); - type_row.set_text(&col.data_type); - if limit_for_existing && !driver_can_alter_existing_column(driver_id) { - type_row.set_sensitive(false); - type_row.set_tooltip_text(Some(&crate::i18n::gettext("Type changes aren't supported by SQLite."))); - } + type_row.set_text(col.column_type.text()); + type_row.set_widget_name(&format!("col-type-{index}")); + gate(&type_row, &permissions.alter_type); + mark_invalid( + &type_row, + type_error(context.dialect.as_deref(), col, col.column_type.text()).as_deref(), + ); let sender_for_type = sender.clone(); let suppress_for_type = suppress_emit.clone(); + let dialect_for_type = context.dialect.clone(); + let column_for_type = col.clone(); type_row.connect_changed(move |e| { if suppress_for_type.get() { return; } + let text = e.text().to_string(); + mark_invalid( + e, + type_error(dialect_for_type.as_deref(), &column_for_type, &text).as_deref(), + ); sender_for_type.input(StructureTabInput::ColumnEdited { index, - field: ColumnField::Type(e.text().to_string()), + field: ColumnField::Type(text), }); }); - let (suggestions_button, suggestions_popover) = build_type_suggestions_button(driver_id, &type_row); - type_row.add_suffix(&suggestions_button); - popover_registry.borrow_mut().push(suggestions_popover); + if type_row.is_sensitive() { + let (suggestions_button, suggestions_popover) = + build_type_suggestions_button(context.type_suggestions, &type_row); + type_row.add_suffix(&suggestions_button); + popover_registry.borrow_mut().push(suggestions_popover); + } row.add_row(&type_row); - // Nullable (AdwSwitchRow). let nullable_row = adw::SwitchRow::builder() .title(crate::i18n::gettext("Nullable")) .active(col.nullable) .build(); - if limit_for_existing && !driver_can_alter_existing_column(driver_id) { - nullable_row.set_sensitive(false); - nullable_row.set_tooltip_text(Some(&crate::i18n::gettext( - "Nullability changes aren't supported by SQLite.", - ))); - } + nullable_row.set_widget_name(&format!("col-nullable-{index}")); + gate(&nullable_row, &permissions.alter_nullability); let sender_for_null = sender.clone(); let suppress_for_null = suppress_emit.clone(); nullable_row.connect_active_notify(move |s| { @@ -246,37 +201,34 @@ pub(super) fn build_column_expander_row( }); row.add_row(&nullable_row); - // Default value (AdwEntryRow). Empty input means no DEFAULT clause. + // SQL, as the engine's parser reads it: a quoted literal or an + // expression. Empty means no DEFAULT clause. let default_row = adw::EntryRow::builder() .title(crate::i18n::gettext("Default value")) .build(); - default_row.set_text(col.default_value.as_deref().unwrap_or("")); - if limit_for_existing && !driver_can_alter_existing_column(driver_id) { - default_row.set_sensitive(false); - default_row.set_tooltip_text(Some(&crate::i18n::gettext( - "Default changes aren't supported by SQLite.", - ))); - } + default_row.set_text(&context.default_text); + default_row.set_widget_name(&format!("col-default-{index}")); + gate(&default_row, &permissions.alter_default); + mark_invalid(&default_row, context.default_error.as_deref()); let sender_for_default = sender.clone(); let suppress_for_default = suppress_emit.clone(); default_row.connect_changed(move |e| { if suppress_for_default.get() { return; } - let text = e.text().to_string(); - let value = if text.is_empty() { None } else { Some(text) }; sender_for_default.input(StructureTabInput::ColumnEdited { index, - field: ColumnField::Default(value), + field: ColumnField::Default(e.text().to_string()), }); }); row.add_row(&default_row); - // Primary key (AdwSwitchRow). let pk_row = adw::SwitchRow::builder() .title(crate::i18n::gettext("Primary key")) .active(col.primary_key) .build(); + pk_row.set_widget_name(&format!("col-pk-{index}")); + gate(&pk_row, &permissions.alter_primary_key); let sender_for_pk = sender.clone(); let suppress_for_pk = suppress_emit.clone(); pk_row.connect_active_notify(move |s| { @@ -290,23 +242,25 @@ pub(super) fn build_column_expander_row( }); row.add_row(&pk_row); - // Auto-increment (AdwSwitchRow). Bound `sensitive` to PK's - // `active` so the affordance reflects the driver-level constraint - // (MySQL rejects AUTO_INCREMENT on non-PK; Postgres SERIAL - // implies PK). + // A numbered column has to be a key on every engine, so the switch + // follows the primary key's where the engine allows the change. let auto_row = adw::SwitchRow::builder() .title(crate::i18n::gettext("Auto increment")) - .subtitle(crate::i18n::gettext("MySQL AUTO_INCREMENT / Postgres SERIAL")) .active(col.auto_increment) .build(); - auto_row.set_sensitive(col.primary_key); - pk_row - .bind_property("active", &auto_row, "sensitive") - .sync_create() - .build(); + auto_row.set_widget_name(&format!("col-auto-{index}")); + match &permissions.alter_auto_increment { + Permission::Allowed => { + auto_row.set_sensitive(col.primary_key); + pk_row + .bind_property("active", &auto_row, "sensitive") + .sync_create() + .build(); + } + refused => gate(&auto_row, refused), + } let sender_for_auto = sender.clone(); let suppress_for_auto = suppress_emit.clone(); - let suppress_emit_for_comment = suppress_emit; auto_row.connect_active_notify(move |s| { if suppress_for_auto.get() { return; @@ -318,17 +272,20 @@ pub(super) fn build_column_expander_row( }); row.add_row(&auto_row); - // Comment (AdwEntryRow). Empty means the column carries no - // description; clearing a description the server had drops it. + // Empty means the column carries no description; clearing one the + // server had drops it. let comment_row = adw::EntryRow::builder().title(crate::i18n::gettext("Comment")).build(); comment_row.set_text(col.comment.as_deref().unwrap_or("")); comment_row.set_widget_name(&format!("col-comment-{index}")); - if !driver_stores_column_comments(driver_id) { - comment_row.set_sensitive(false); - comment_row.set_tooltip_text(Some(&crate::i18n::gettext("SQLite doesn't store column comments."))); - } + gate( + &comment_row, + &Permission::unless( + context.column_comments, + tablepro_core::ddl::RefusalReason::EngineUnsupported, + ), + ); let sender_for_comment = sender; - let suppress_for_comment = suppress_emit_for_comment; + let suppress_for_comment = suppress_emit; comment_row.connect_changed(move |e| { if suppress_for_comment.get() { return; @@ -350,9 +307,10 @@ pub(super) fn build_column_expander_row( /// two columns' menus never share the `types.apply` action. /// /// The popover comes back too, to be popped down before its row goes. -fn build_type_suggestions_button(driver_id: &str, target: &adw::EntryRow) -> (gtk::MenuButton, gtk::Popover) { - // Per-button action group: one action `apply` keyed by `String` - // parameter. Each menu item activates `types.apply::`. +fn build_type_suggestions_button( + suggestions: &'static [&'static str], + target: &adw::EntryRow, +) -> (gtk::MenuButton, gtk::Popover) { let action_group = gio::SimpleActionGroup::new(); let apply_action = gio::SimpleAction::new("apply", Some(&String::static_variant_type())); // The entry owns this button and its actions. @@ -364,12 +322,8 @@ fn build_type_suggestions_button(driver_id: &str, target: &adw::EntryRow) -> (gt }); action_group.add_action(&apply_action); - // Build the menu model: every type becomes a labeled item that - // activates the apply action with the type string as parameter. - // gtk::PopoverMenu renders each gio::MenuItem as a native menu - // entry (no separator handling needed for a flat list). let menu = gio::Menu::new(); - for ty in driver_types(driver_id) { + for ty in suggestions { let item = gio::MenuItem::new(Some(ty), None); item.set_action_and_target_value(Some("types.apply"), Some(&ty.to_variant())); menu.append_item(&item); @@ -382,8 +336,6 @@ fn build_type_suggestions_button(driver_id: &str, target: &adw::EntryRow) -> (gt .build(); button.add_css_class("flat"); button.insert_action_group("types", Some(&action_group)); - // The popover is handed back so the caller can `popdown` it before - // the owning column row is torn down on Refresh. let popover = gtk::PopoverMenu::from_model(Some(&menu)); button.set_popover(Some(&popover)); (button, popover.upcast()) diff --git a/linux/crates/app/src/ui/structure_tab/ddl_text.rs b/linux/crates/app/src/ui/structure_tab/ddl_text.rs new file mode 100644 index 0000000000..a453062d0a --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/ddl_text.rs @@ -0,0 +1,92 @@ +//! The structure tab's DDL refusals and problems in the user's words. + +use tablepro_core::ddl::{BuildDdlError, DdlOperation, DraftProblem, RefusalReason}; + +/// Why a control is off, for its tooltip. +pub(crate) fn refusal_text(reason: &RefusalReason) -> String { + match reason { + RefusalReason::EngineUnsupported => crate::i18n::gettext("This database can't make this change."), + RefusalReason::KeyColumn => crate::i18n::gettext( + "This column is part of the table's sorting or partition key, which only rebuilding the table can change.", + ), + RefusalReason::GeneratedColumn => crate::i18n::gettext("The database computes this column."), + RefusalReason::IdentityColumn => { + crate::i18n::gettext("The database numbers this column, so it has no default.") + } + RefusalReason::UnmodeledAttribute(attribute) => crate::i18n::gettext_f( + "This column has an attribute TablePro can't restate: {attribute}", + &[("attribute", attribute)], + ), + RefusalReason::ColumnInUse(users) => { + crate::i18n::gettext_f("Still used by {users}.", &[("users", &users.join(", "))]) + } + RefusalReason::ReferencedByForeignKeys(keys) => crate::i18n::gettext_f( + "Foreign keys on other tables depend on it: {keys}", + &[("keys", &keys.join(", "))], + ), + RefusalReason::UnreadableDefinition => { + crate::i18n::gettext("The database didn't report this column's full definition.") + } + } +} + +/// The first thing to fix before Save. +pub(crate) fn draft_problem_text(problem: &DraftProblem) -> String { + match problem { + DraftProblem::EmptyTableName => crate::i18n::gettext("Table name is required."), + DraftProblem::NoColumns => crate::i18n::gettext("At least one column is required."), + DraftProblem::EmptyColumnName => crate::i18n::gettext("Every column needs a name."), + DraftProblem::DuplicateColumnName(name) => { + crate::i18n::gettext_f("Duplicate column name: {name}", &[("name", name)]) + } + DraftProblem::EmptyColumnType(name) => crate::i18n::gettext_f("Column {name} needs a type.", &[("name", name)]), + DraftProblem::NullablePrimaryKey(name) => { + crate::i18n::gettext_f("Primary key columns must be NOT NULL: {name}", &[("name", name)]) + } + DraftProblem::ColumnInUse { column, used_by } => crate::i18n::gettext_f( + "Column {column} is removed but {users} still use it. Remove them first.", + &[("column", column), ("users", &used_by.join(", "))], + ), + DraftProblem::EmptyIndexName => crate::i18n::gettext("Index name is required."), + DraftProblem::IndexWithoutKeys(name) => { + crate::i18n::gettext_f("Index {name} has no columns.", &[("name", name)]) + } + DraftProblem::EmptyForeignKeyName => crate::i18n::gettext("Foreign key name is required."), + DraftProblem::ForeignKeyColumnMismatch(name) => crate::i18n::gettext_f( + "Foreign key {name} needs as many referenced columns as source columns.", + &[("name", name)], + ), + } +} + +/// Why the change could not be written as SQL. +pub(crate) fn build_error_text(error: &BuildDdlError) -> String { + match error { + BuildDdlError::Draft(problem) => draft_problem_text(problem), + BuildDdlError::Refused { column, reason, .. } => crate::i18n::gettext_f( + "Column {column} can't be changed this way. {reason}", + &[("column", column), ("reason", &refusal_text(reason))], + ), + BuildDdlError::Unsupported(DdlOperation::AddRequiredColumnWithoutDefault) => crate::i18n::gettext( + "A NOT NULL column needs a default here, since the rows already in the table need a value.", + ), + BuildDdlError::Unsupported(_) => crate::i18n::gettext("This database can't make this change."), + BuildDdlError::InvalidType { column, error } => crate::i18n::gettext_f( + "Column {column} has a type this database doesn't accept: {error}", + &[("column", column), ("error", &error.to_string())], + ), + BuildDdlError::Literal { column, error } => crate::i18n::gettext_f( + "The default of column {column} can't be written as SQL: {error}", + &[("column", column), ("error", &error.to_string())], + ), + BuildDdlError::MissingCatalogDeclaration(column) => crate::i18n::gettext_f( + "The database didn't report the type of column {column}.", + &[("column", column)], + ), + BuildDdlError::ReferentialAction(action) => crate::i18n::gettext_f( + "This database doesn't take {action} on a foreign key.", + &[("action", action.as_sql())], + ), + BuildDdlError::NoTableSource => crate::i18n::gettext("The database gave no CREATE statement for this table."), + } +} diff --git a/linux/crates/app/src/ui/structure_tab/fks.rs b/linux/crates/app/src/ui/structure_tab/fks.rs index 05be5e595b..3fc84a5367 100644 --- a/linux/crates/app/src/ui/structure_tab/fks.rs +++ b/linux/crates/app/src/ui/structure_tab/fks.rs @@ -6,21 +6,18 @@ use libadwaita as adw; use libadwaita::prelude::*; use tablepro_core::ForeignKeyInfo; -use tablepro_core::ddl::ReferentialAction; +use tablepro_core::ddl::{Permission, ReferentialAction}; +use super::columns::gate; use super::{StructureTabInput, StructureTabSender}; -fn driver_can_drop_foreign_key(driver_id: &str) -> bool { - !matches!(driver_id, "sqlite") -} - /// Foreign-key row as a native `AdwActionRow`. Subtitle encodes both /// local columns and the reference target so users see the full /// relationship in a glance: `col_a, col_b → other_table (ref_a, ref_b)`. pub(super) fn build_fk_row( index: usize, fk: &ForeignKeyInfo, - driver_id: &str, + removable: &Permission, sender: StructureTabSender, ) -> adw::ActionRow { let qualified_ref = match &fk.ref_schema { @@ -53,12 +50,8 @@ pub(super) fn build_fk_row( .valign(gtk::Align::Center) .build(); remove_button.add_css_class("flat"); - if !driver_can_drop_foreign_key(driver_id) { - remove_button.set_sensitive(false); - remove_button.set_tooltip_text(Some(&crate::i18n::gettext( - "Dropping a foreign key isn't supported by SQLite.", - ))); - } + remove_button.set_widget_name(&format!("fk-remove-{index}")); + gate(&remove_button, removable); let sender_for_remove = sender.clone(); remove_button.connect_clicked(move |_| sender_for_remove.input(StructureTabInput::RemoveForeignKey(index))); row.add_suffix(&remove_button); diff --git a/linux/crates/app/src/ui/structure_tab/imp.rs b/linux/crates/app/src/ui/structure_tab/imp.rs index fb0f633f02..7abaa80e33 100644 --- a/linux/crates/app/src/ui/structure_tab/imp.rs +++ b/linux/crates/app/src/ui/structure_tab/imp.rs @@ -78,9 +78,7 @@ impl ObjectImpl for StructureTab { Signal::builder("dirty-changed") .param_types([bool::static_type()]) .build(), - Signal::builder("execute") - .param_types([glib::BoxedAnyObject::static_type()]) - .build(), + Signal::builder("save-requested").build(), Signal::builder("drop-table-requested") .param_types([String::static_type(), String::static_type()]) .build(), diff --git a/linux/crates/app/src/ui/structure_tab/indexes.rs b/linux/crates/app/src/ui/structure_tab/indexes.rs index 74042bf438..7cab2d8632 100644 --- a/linux/crates/app/src/ui/structure_tab/indexes.rs +++ b/linux/crates/app/src/ui/structure_tab/indexes.rs @@ -6,8 +6,10 @@ use libadwaita as adw; use libadwaita::prelude::*; use tablepro_core::IndexInfo; +use tablepro_core::ddl::Permission; use tablepro_core::meta::IndexKey; +use super::columns::gate; use super::{StructureTabInput, StructureTabSender}; fn index_badge(label: &str, accent_class: &str) -> gtk::Label { @@ -17,7 +19,12 @@ fn index_badge(label: &str, accent_class: &str) -> gtk::Label { badge } -pub(super) fn build_index_row(index: usize, idx: &IndexInfo, sender: StructureTabSender) -> adw::ActionRow { +pub(super) fn build_index_row( + index: usize, + idx: &IndexInfo, + removable: &Permission, + sender: StructureTabSender, +) -> adw::ActionRow { let subtitle = index_key_summary(idx); let row = adw::ActionRow::builder() .title(glib::markup_escape_text(&idx.name)) @@ -37,12 +44,15 @@ pub(super) fn build_index_row(index: usize, idx: &IndexInfo, sender: StructureTa .valign(gtk::Align::Center) .build(); remove_button.add_css_class("flat"); + remove_button.set_widget_name(&format!("index-remove-{index}")); // The primary key's constraint owns its index. if idx.primary { remove_button.set_sensitive(false); remove_button.set_tooltip_text(Some(&crate::i18n::gettext( "Primary-key index can't be dropped here; clear the PK on the column.", ))); + } else { + gate(&remove_button, removable); } let sender_for_remove = sender.clone(); remove_button.connect_clicked(move |_| sender_for_remove.input(StructureTabInput::RemoveIndex(index))); diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index e4ff8eaf53..d67707dbb2 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -17,23 +17,21 @@ //! ├─ ActionBar { pending count | Discard | Save | Drop } ────┤ //! └──────────────────────────────────────────────────────────┘ //! -//! Editing flow (snapshot + diff). On load we capture the canonical -//! schema into `original_*` snapshots. Every cell mutation updates -//! the live model and calls `recompute_dirty_state`, which runs -//! `sql_ddl::diff_to_ops` against the snapshot, regenerates the SQL -//! preview from the resulting `Vec`, and stores those -//! ops in a passive per-tab `StructureChangeTracker` so out-of-band -//! callers (close-with-pending dialog, save dispatcher) can read the -//! current dirty state without touching the UI. There is no per-op -//! undo or redo: the snapshot is the only restore point, exposed via -//! the Discard button. Save calls `materialize_ops` against the same -//! diff and asks the window to run the statements. +//! The tab edits a `TableDraft` started from the table's definition as +//! one read returned it. Every edit diffs the draft against that +//! definition, plans the change through the connection's `DdlDialect` +//! for the preview, and hands the pending structure to the tab's +//! tracker, where the window's close and save paths read it. Each +//! control is on only where the dialect allows its change, with the +//! reason in its tooltip where it does not. Discard copies the +//! definition back over the draft; there is no per-change undo. //! //! The tab reads the table's definition itself, one request at a time, //! and reads it again once the session reconnects if the last read //! failed. mod columns; +mod ddl_text; mod fks; mod imp; mod indexes; @@ -52,19 +50,25 @@ use gtk4::prelude::*; use gtk4::subclass::prelude::*; use libadwaita as adw; use sourceview5::prelude::BufferExt; -use tablepro_core::ddl::{ColumnDefinition, TableDefinition}; +use tablepro_core::column::ColumnDefault; +use tablepro_core::ddl::{ + ColumnCapabilities, DdlCapabilities, DdlDialect, DraftColumn, DraftType, Permission, RefusalReason, TableChanges, + TableDefinition, TableDraft, default_text, diff_table, parse_default, primary_key_permission, +}; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; -use tablepro_core::sql_ddl::{BuildDdlError, DraftColumn, StructureOp, diff_to_ops, materialize_ops}; use tablepro_core::{ForeignKeyInfo, IndexInfo}; use tablepro_session::runtime::{Tasks, WatchForwarder}; use tablepro_session::{LinkGeneration, OperationError, OperationKind, Session}; -use self::columns::{build_column_expander_row, default_type_for}; +use self::columns::{ColumnRowContext, build_column_expander_row}; +pub(crate) use self::ddl_text::build_error_text; +use self::ddl_text::draft_problem_text; use self::fks::build_fk_row; use self::indexes::build_index_row; pub(crate) use self::structure_command::{LoadedStructure, StructureCommand, StructureRequestKind}; use crate::runtime::{FailedRequests, RequestSequencer, RequestTicket, Stamped}; +use crate::services::pending_structure::PendingStructure; use crate::services::structure_tracker_handle::StructureTrackerHandle; use crate::ui::handler_guard::HandlerGuard; use crate::ui::structure_tab_dialogs::{present_fk_dialog, present_index_dialog}; @@ -88,7 +92,6 @@ pub struct StructureTabInit { pub schema: Option, pub table: String, pub mode: StructureMode, - pub driver_id: String, /// The session the tab reads through. A draft takes its dialect /// from it for the SQL preview. pub session: Arc, @@ -160,28 +163,21 @@ impl StructureTab { let model = StructureModel { this: tab.downgrade(), link_generation: init.session.link_generation(), - dialect: Rc::new(RefCell::new(match init.mode { + dialect: match init.mode { // A draft has no table to read the dialect back with. StructureMode::New => init.session.current_dialect(), StructureMode::Edit => None, - })), + }, session: init.session, tasks: init.tasks, sequencer: RequestSequencer::new(), failed: FailedRequests::new(), issued_on: BTreeMap::new(), tracker: init.tracker, - schema: init.schema, - original_table_name: Rc::new(RefCell::new(init.table.clone())), - table_name: Rc::new(RefCell::new(init.table)), - mode: Rc::new(RefCell::new(init.mode)), - driver_id: init.driver_id, - columns: Rc::new(RefCell::new(Vec::new())), - indexes: Rc::new(RefCell::new(Vec::new())), - foreign_keys: Rc::new(RefCell::new(Vec::new())), - original_columns: Rc::new(RefCell::new(Vec::new())), - original_indexes: Rc::new(RefCell::new(Vec::new())), - original_fks: Rc::new(RefCell::new(Vec::new())), + mode: init.mode, + definition: None, + draft: Rc::new(RefCell::new(TableDraft::new(TableRef::new(init.schema, init.table)))), + invalid_defaults: BTreeMap::new(), inner_stack: imp.inner_stack.get(), error_status: imp.error_status.get(), name_entry: imp.name_entry.get(), @@ -194,11 +190,11 @@ impl StructureTab { save_button: imp.save_button.get(), discard_button: imp.discard_button.get(), drop_button: imp.drop_button.get(), - last_dirty: Rc::new(RefCell::new(false)), + last_dirty: false, suppress_emit: imp.suppress_emit.clone(), - next_column_seq: Rc::new(RefCell::new(1)), + next_column_seq: 1, column_popovers: Rc::new(RefCell::new(Vec::new())), - refetching: Rc::new(Cell::new(false)), + refetching: false, definition_shown: false, _theme_handler: theme_handler, }; @@ -277,15 +273,9 @@ impl StructureTab { ) } - /// Save asked the window to run these statements. - pub fn connect_execute) + 'static>(&self, f: F) -> glib::SignalHandlerId { - self.connect_closure( - "execute", - false, - glib::closure_local!(move |tab: &Self, statements: glib::BoxedAnyObject| { - f(tab, statements.borrow::>().clone()); - }), - ) + /// Save asked the window to write what the tab's tracker holds. + pub fn connect_save_requested(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure("save-requested", false, glib::closure_local!(move |tab: &Self| f(tab))) } pub fn connect_drop_table_requested, String) + 'static>( @@ -318,9 +308,7 @@ impl StructureTab { fn emit_output(&self, output: StructureTabOutput) { match output { StructureTabOutput::DirtyChanged(dirty) => self.emit_by_name::<()>("dirty-changed", &[&dirty]), - StructureTabOutput::ExecuteTransaction { statements } => { - self.emit_by_name::<()>("execute", &[&glib::BoxedAnyObject::new(statements)]); - } + StructureTabOutput::SaveRequested => self.emit_by_name::<()>("save-requested", &[]), StructureTabOutput::DropTableRequested { schema, table } => { self.emit_by_name::<()>("drop-table-requested", &[&schema, &table]); } @@ -381,24 +369,19 @@ pub(crate) struct StructureModel { /// The newest connection the tab has heard of. link_generation: LinkGeneration, tracker: StructureTrackerHandle, - schema: Option, - table_name: Rc>, - mode: Rc>, - driver_id: String, - columns: Rc>>, - indexes: Rc>>, - foreign_keys: Rc>>, - /// The table as the last read returned it. Save writes the diff - /// between these and the live fields, and Discard copies these back - /// over them. - original_table_name: Rc>, - original_columns: Rc>>, - original_indexes: Rc>>, - original_fks: Rc>>, - /// The connection's dialect, which spells renames and default drops - /// per server. `None` until the first read, and the SQL cannot be - /// written before then. - dialect: Rc>>>, + mode: StructureMode, + /// The table as the last read returned it, which Save plans against + /// and Discard restores. `None` for a new table. + definition: Option, + /// What the user is editing. Shared with the dialogs, which list its + /// columns. + draft: Rc>, + /// Default text the engine's parser refused, by column, kept so a + /// rebuild shows what the user typed. Save waits until it is empty. + invalid_defaults: BTreeMap, + /// The connection's dialect, which plans the DDL. `None` until the + /// first read, and the SQL cannot be written before then. + dialect: Option>, inner_stack: gtk::Stack, /// The status page shown when the read fails, with the driver's @@ -416,30 +399,30 @@ pub(crate) struct StructureModel { discard_button: gtk::Button, drop_button: gtk::Button, - last_dirty: Rc>, + last_dirty: bool, /// Suppress reentrant rebuilds: programmatic `set_text` / /// `set_active` while seeding row widgets fires `changed` / /// `notify::active` signals synchronously. Without this guard, /// the row's connect_* callbacks would treat seeding as a user - /// edit and shift the live model away from the snapshot, surfacing + /// edit and shift the draft away from the definition, surfacing /// phantom pending changes. `Cell` (not `RefCell`) because GTK can /// fire those signals re-entrantly during `clear_box` while /// another `borrow_mut` is still live on the stack. suppress_emit: Rc>, /// Monotonic counter for the "Add Column" placeholder name, so a /// removed column does not leave its name to be handed out twice. - next_column_seq: Rc>, + next_column_seq: usize, /// The type suggestion popovers of the column rows. A rebuild pops /// them down before it removes the rows, so no menu stays open over /// a row that is gone. column_popovers: Rc>>, - /// True between `SaveCompleted` and the read that follows. The live - /// model is already committed while the `original_*` snapshots - /// still hold the pre-save state, so diffing them would surface - /// phantom pending ops; `recompute_dirty_state` waits it out. - refetching: Rc>, - /// The editor shows a definition that `original_*` still match, so - /// a read of it that fails can leave it on screen. + /// True between `SaveCompleted` and the read that follows. The draft + /// is already committed while the definition still holds the + /// pre-save state, so diffing them would surface phantom pending + /// changes; `recompute_dirty_state` waits it out. + refetching: bool, + /// The editor shows a definition the draft still started from, so a + /// read of it that fails can leave it on screen. definition_shown: bool, /// The SQL preview follows the desktop theme through the app-wide /// style manager, which would keep the handler past the tab. @@ -487,17 +470,25 @@ pub enum ColumnField { Nullable(bool), PrimaryKey(bool), AutoIncrement(bool), - Default(Option), + /// SQL text; empty is no default. + Default(String), Comment(Option), } #[derive(Debug)] pub(crate) enum StructureTabOutput { DirtyChanged(bool), - ExecuteTransaction { statements: Vec }, - DropTableRequested { schema: Option, table: String }, + /// Save asked the window to write the tracker's pending structure. + SaveRequested, + DropTableRequested { + schema: Option, + table: String, + }, ShowToast(String), - ShowAlert { title: String, body: String }, + ShowAlert { + title: String, + body: String, + }, } impl StructureModel { @@ -505,74 +496,62 @@ impl StructureModel { StructureTabSender { tab: self.this.clone() } } - /// What Save would write: the diff against the snapshot, or one - /// CREATE TABLE for a draft with columns. - fn current_diff_ops(&self) -> Vec { - if matches!(*self.mode.borrow(), StructureMode::New) { - let columns = self.columns.borrow().clone(); - if columns.is_empty() { - return Vec::new(); - } - return vec![StructureOp::CreateTable { - schema: self.schema.clone(), - table: self.table_name.borrow().clone(), - columns, - indexes: self.indexes.borrow().clone(), - fks: self.foreign_keys.borrow().clone(), - }]; + fn ddl(&self) -> Option<&dyn DdlDialect> { + self.dialect.as_deref().and_then(|dialect| dialect.ddl()) + } + + /// What Save would write: a new table, or the changes between the + /// definition and the draft. + fn pending(&self) -> PendingStructure { + let draft = self.draft.borrow().clone(); + let original = match self.mode { + StructureMode::New => None, + StructureMode::Edit => self.definition.clone(), + }; + let changes = match &original { + Some(original) => diff_table(original, &draft), + None => TableChanges::default(), + }; + PendingStructure { + original, + draft, + changes, } - diff_to_ops( - self.schema.as_deref(), - &self.original_table_name.borrow(), - &self.table_name.borrow(), - &self.original_columns.borrow(), - &self.columns.borrow(), - &self.original_indexes.borrow(), - &self.indexes.borrow(), - &self.original_fks.borrow(), - &self.foreign_keys.borrow(), - ) } /// The action bar, the preview and the tracker after an edit. The - /// tracker holds the ops so the window's close and save paths read - /// them without the tab. + /// tracker holds the pending structure so the window's close and save + /// paths read it without the tab. /// /// Waits while `refetching`: between `SaveCompleted` and the read - /// after it, `original_*` is stale. - fn recompute_dirty_state(&self, fx: &mut StructureEffects) { - if self.refetching.get() { + /// after it, the definition is stale. + fn recompute_dirty_state(&mut self, fx: &mut StructureEffects) { + if self.refetching { return; } - let ops = self.current_diff_ops(); - let count = ops.len(); - self.refresh_buttons(count); - self.regenerate_sql_preview_from(&ops); - self.tracker.set_ops(ops); - - let dirty = count > 0; - let mut last = self.last_dirty.borrow_mut(); - if *last != dirty { - *last = dirty; + let pending = self.pending(); + let dirty = pending.has_changes(); + self.refresh_buttons(pending.change_count()); + self.regenerate_sql_preview(&pending); + self.tracker.set_pending(Some(pending)); + if self.last_dirty != dirty { + self.last_dirty = dirty; fx.output(StructureTabOutput::DirtyChanged(dirty)); } } fn refresh_buttons(&self, pending_count: usize) { let has_pending = pending_count > 0; - self.save_button.set_sensitive(has_pending); + self.save_button + .set_sensitive(has_pending && self.invalid_defaults.is_empty()); self.discard_button.set_sensitive(has_pending); if has_pending { - let label = if pending_count == 1 { - crate::i18n::gettext("1 pending change") - } else { - crate::i18n::ngettext_f( - "{n} pending change", - "{n} pending changes", - u32::try_from(pending_count).unwrap_or(u32::MAX), - &[("n", &pending_count.to_string())], - ) - }; + let label = crate::i18n::ngettext_f( + "{n} pending change", + "{n} pending changes", + u32::try_from(pending_count).unwrap_or(u32::MAX), + &[("n", &pending_count.to_string())], + ); self.pending_label.set_label(&label); self.pending_label.set_visible(true); } else { @@ -580,21 +559,58 @@ impl StructureModel { } } - /// The statements `ops` become on this connection, or `None` while - /// the tab has no dialect to spell them in. - fn materialize(&self, ops: &[StructureOp]) -> Option, BuildDdlError>> { - let dialect = self.dialect.borrow().clone()?; - Some(materialize_ops(ops, &self.driver_id, &*dialect)) + fn regenerate_sql_preview(&self, pending: &PendingStructure) { + let text = match (&self.dialect, self.ddl()) { + (None, _) => crate::i18n::gettext("-- Connect to a database to see the SQL."), + (Some(_), None) => crate::i18n::gettext("-- This database's structure can't be edited here."), + (Some(_), Some(_)) if !self.invalid_defaults.is_empty() => { + crate::i18n::gettext("-- Fix the highlighted fields to see the SQL.") + } + (Some(_), Some(_)) if !pending.has_changes() => crate::i18n::gettext("-- No pending changes."), + (Some(_), Some(ddl)) => match pending.plan(ddl) { + Ok(plan) => plan.preview(), + Err(error) => format!("-- {}", build_error_text(&error)), + }, + }; + self.sql_buffer.set_text(&text); + } + + fn capabilities(&self) -> DdlCapabilities { + self.ddl().map(|ddl| *ddl.capabilities()).unwrap_or_default() } - fn regenerate_sql_preview_from(&self, ops: &[StructureOp]) { - let text = match self.materialize(ops) { - Some(Ok(stmts)) if !stmts.is_empty() => stmts.join(";\n\n") + ";", - Some(Ok(_)) => crate::i18n::gettext("-- No pending changes."), - Some(Err(e)) => format!("-- {e}"), - None => crate::i18n::gettext("-- Connect to a database to see the SQL."), + /// Which changes the row of `column` offers. A column the user added + /// in this tab is only a draft, so everything about it can change, + /// apart from joining the primary key of a table whose key is held. + fn column_permissions(&self, column: &DraftColumn) -> ColumnCapabilities { + let capabilities = self.capabilities(); + let (Some(ddl), Some(definition), Some(original)) = ( + self.ddl(), + self.definition.as_ref().filter(|_| self.mode == StructureMode::Edit), + column.original.as_ref(), + ) else { + let primary_key = match (self.mode, &self.definition) { + (StructureMode::Edit, Some(definition)) => primary_key_permission(&capabilities, definition), + _ => Permission::Allowed, + }; + return ColumnCapabilities { + rename: Permission::Allowed, + drop: Permission::Allowed, + alter_type: Permission::Allowed, + alter_nullability: Permission::Allowed, + alter_default: Permission::Allowed, + alter_primary_key: primary_key, + alter_auto_increment: Permission::Allowed, + }; }; - self.sql_buffer.set_text(&text); + ddl.column_capabilities(definition, original) + } + + /// The type a default's literal is read against: the column's own, or + /// the one the user typed once it parses. + fn default_column_type(&self, column: &DraftColumn) -> Option { + let dialect = self.dialect.as_deref()?; + column.column_type.column_type(dialect, &column.name).ok() } fn rebuild_columns_view(&self) { @@ -609,23 +625,43 @@ impl StructureModel { } } clear_box(&self.columns_box); - let driver_id = self.driver_id.clone(); + let capabilities = self.capabilities(); + let type_suggestions = self.ddl().map(|ddl| ddl.type_suggestions()).unwrap_or_default(); let list = boxed_list(); - for (i, col) in self.columns.borrow().iter().enumerate() { + let columns = self.draft.borrow().columns.clone(); + for (i, col) in columns.iter().enumerate() { + let (default_text, default_error) = match self.invalid_defaults.get(&i) { + Some((text, error)) => (text.clone(), Some(error.clone())), + None => (self.default_text(col), None), + }; + let context = ColumnRowContext { + permissions: self.column_permissions(col), + type_suggestions, + column_comments: capabilities.column_comments, + dialect: self.dialect.clone(), + default_text, + default_error, + }; list.append(&build_column_expander_row( i, col, - &driver_id, + context, self.sender(), self.suppress_emit.clone(), self.column_popovers.clone(), )); } - let sender_for_add = self.sender(); - append_add_button(&list, &crate::i18n::gettext("Add Column"), move || { - sender_for_add.input(StructureTabInput::AddColumn); - }); + let can_add = match self.mode { + StructureMode::New => true, + StructureMode::Edit => capabilities.add_column, + }; + if can_add { + let sender_for_add = self.sender(); + append_add_button(&list, &crate::i18n::gettext("Add Column"), move || { + sender_for_add.input(StructureTabInput::AddColumn); + }); + } self.columns_box.append(&list); let suppress = self.suppress_emit.clone(); @@ -634,53 +670,85 @@ impl StructureModel { }); } + fn default_text(&self, column: &DraftColumn) -> String { + match (self.dialect.as_deref(), self.default_column_type(column)) { + (Some(dialect), Some(column_type)) => default_text(dialect, &column.default, &column_type), + _ => match &column.default { + ColumnDefault::None => String::new(), + ColumnDefault::Literal(value) => tablepro_core::export::value_to_text(value).unwrap_or_default(), + ColumnDefault::Expression(expression) => expression.as_sql().to_owned(), + }, + } + } + + /// A draft's indexes and foreign keys are only entries in it, so the + /// engine's limits apply to a table that exists. + fn table_permission(&self, allowed: bool) -> Permission { + match self.mode { + StructureMode::New => Permission::Allowed, + StructureMode::Edit => Permission::unless(allowed, RefusalReason::EngineUnsupported), + } + } + fn rebuild_indexes_view(&self) { clear_box(&self.indexes_box); + let capabilities = self.capabilities(); + let removable = self.table_permission(capabilities.drop_index); let list = boxed_list(); - for (i, idx) in self.indexes.borrow().iter().enumerate() { - list.append(&build_index_row(i, idx, self.sender())); + for (i, idx) in self.draft.borrow().indexes.iter().enumerate() { + list.append(&build_index_row(i, idx, &removable, self.sender())); + } + if capabilities.create_index { + let draft_for_dialog = self.draft.clone(); + let sender_for_add = self.sender(); + let parent_box = self.indexes_box.downgrade(); + append_add_button(&list, &crate::i18n::gettext("Add Index…"), move || { + if let Some(parent_box) = parent_box.upgrade() { + present_index_dialog( + parent_box.upcast_ref(), + &draft_for_dialog.borrow().columns, + sender_for_add.clone(), + ); + } + }); } - let columns_for_dialog = self.columns.clone(); - let sender_for_add = self.sender(); - let parent_box = self.indexes_box.downgrade(); - append_add_button(&list, &crate::i18n::gettext("Add Index…"), move || { - if let Some(parent_box) = parent_box.upgrade() { - present_index_dialog( - parent_box.upcast_ref(), - &columns_for_dialog.borrow(), - sender_for_add.clone(), - ); - } - }); self.indexes_box.append(&list); } fn rebuild_fks_view(&self) { clear_box(&self.fks_box); - let driver_id = self.driver_id.clone(); + let capabilities = self.capabilities(); + let removable = self.table_permission(capabilities.drop_foreign_key); let list = boxed_list(); - for (i, fk) in self.foreign_keys.borrow().iter().enumerate() { - list.append(&build_fk_row(i, fk, &driver_id, self.sender())); + for (i, fk) in self.draft.borrow().foreign_keys.iter().enumerate() { + list.append(&build_fk_row(i, fk, &removable, self.sender())); + } + // A new table declares its keys inside CREATE TABLE, which every + // engine that creates tables takes. + let can_add = match self.mode { + StructureMode::New => capabilities.create_table, + StructureMode::Edit => capabilities.add_foreign_key, + }; + if let Some(actions) = self.ddl().map(|ddl| ddl.referential_actions()).filter(|_| can_add) { + let draft_for_dialog = self.draft.clone(); + let sender_for_add = self.sender(); + let parent_box = self.fks_box.downgrade(); + append_add_button(&list, &crate::i18n::gettext("Add Foreign Key…"), move || { + if let Some(parent_box) = parent_box.upgrade() { + present_fk_dialog( + parent_box.upcast_ref(), + &draft_for_dialog.borrow().columns, + actions, + sender_for_add.clone(), + ); + } + }); } - let columns_for_dialog = self.columns.clone(); - let sender_for_add = self.sender(); - let parent_box = self.fks_box.downgrade(); - let driver_id_for_dialog = driver_id.clone(); - append_add_button(&list, &crate::i18n::gettext("Add Foreign Key…"), move || { - if let Some(parent_box) = parent_box.upgrade() { - present_fk_dialog( - parent_box.upcast_ref(), - &columns_for_dialog.borrow(), - &driver_id_for_dialog, - sender_for_add.clone(), - ); - } - }); self.fks_box.append(&list); } fn table_ref(&self) -> TableRef { - TableRef::new(self.schema.clone(), self.table_name.borrow().clone()) + self.draft.borrow().table.clone() } /// Read the table's definition in one call, so the tab rebuilds @@ -722,27 +790,15 @@ impl StructureModel { } fn on_loaded(&mut self, loaded: LoadedStructure, fx: &mut StructureEffects) { - // Every list goes into `original_*` too, so Discard restores the - // loaded state without a read, including columns the user later - // removed. - let TableDefinition { - columns, - indexes, - foreign_keys, - .. - } = loaded.definition; - *self.dialect.borrow_mut() = Some(loaded.dialect); - *self.original_columns.borrow_mut() = columns.clone(); - *self.columns.borrow_mut() = columns.into_iter().map(DraftColumn::from_definition).collect(); - *self.indexes.borrow_mut() = indexes.clone(); - *self.original_indexes.borrow_mut() = indexes; - *self.foreign_keys.borrow_mut() = foreign_keys.clone(); - *self.original_fks.borrow_mut() = foreign_keys; + self.dialect = Some(loaded.dialect); + *self.draft.borrow_mut() = TableDraft::from_definition(&loaded.definition); + self.definition = Some(loaded.definition); + self.invalid_defaults.clear(); self.inner_stack.set_visible_child_name("editor"); self.definition_shown = true; - // The snapshots are current again, so the diff counts; it is + // The definition is current again, so the diff counts; it is // empty now, which also clears anything typed during the read. - self.refetching.set(false); + self.refetching = false; self.recompute_dirty_state(fx); fx.input(StructureTabInput::Refresh); } @@ -762,7 +818,7 @@ impl StructureModel { self.inner_stack.set_visible_child_name("error"); // A retry from the error page must not find dirty tracking still // frozen. - self.refetching.set(false); + self.refetching = false; } fn on_command(&mut self, command: StructureCommand, fx: &mut StructureEffects) { @@ -785,14 +841,15 @@ impl StructureModel { /// The session reconnected: a draft takes the new connection's /// dialect, and a read that failed is asked again. - fn on_link_changed(&mut self, generation: LinkGeneration) { + fn on_link_changed(&mut self, generation: LinkGeneration, fx: &mut StructureEffects) { if generation <= self.link_generation { return; } self.link_generation = generation; - if matches!(*self.mode.borrow(), StructureMode::New) { - *self.dialect.borrow_mut() = self.session.current_dialect(); - self.regenerate_sql_preview_from(&self.current_diff_ops()); + if self.mode == StructureMode::New { + self.dialect = self.session.current_dialect(); + self.recompute_dirty_state(fx); + fx.input(StructureTabInput::Refresh); } self.retry_failed(); } @@ -809,17 +866,17 @@ impl StructureModel { fn update(&mut self, msg: StructureTabInput, fx: &mut StructureEffects) { match msg { StructureTabInput::Start => { - if matches!(*self.mode.borrow(), StructureMode::Edit) { + if self.mode == StructureMode::Edit { self.fetch_definition(); } fx.input(StructureTabInput::Refresh); } StructureTabInput::Command(command) => self.on_command(command, fx), - StructureTabInput::LinkChanged(generation) => self.on_link_changed(generation), + StructureTabInput::LinkChanged(generation) => self.on_link_changed(generation, fx), StructureTabInput::SchemaChanged => { - // Reading the table again replaces the live model, so a - // tab with edits of its own keeps them. - if matches!(*self.mode.borrow(), StructureMode::Edit) && !*self.last_dirty.borrow() { + // Reading the table again replaces the draft, so a tab + // with edits of its own keeps them. + if self.mode == StructureMode::Edit && !self.last_dirty { self.fetch_definition(); } } @@ -833,140 +890,101 @@ impl StructureModel { self.rebuild_columns_view(); self.rebuild_indexes_view(); self.rebuild_fks_view(); - self.regenerate_sql_preview_from(&self.current_diff_ops()); + self.regenerate_sql_preview(&self.pending()); } StructureTabInput::TableNameEdited(text) => { - let prev = self.table_name.borrow().clone(); - if prev == text { - return; - } - *self.table_name.borrow_mut() = text; - self.recompute_dirty_state(fx); - } - StructureTabInput::ColumnEdited { index, field } => { - let mut cols = self.columns.borrow_mut(); - let Some(col) = cols.get_mut(index) else { - return; - }; - let prev = col.clone(); - match field { - ColumnField::Name(s) => col.name = s, - ColumnField::Type(s) => col.data_type = s, - ColumnField::Nullable(b) => col.nullable = b, - ColumnField::PrimaryKey(b) => { - col.primary_key = b; - // Auto-increment requires a key in every supported - // driver; the checkbox only greys out, so the - // model drops it here. - if !b { - col.auto_increment = false; - } - } - ColumnField::AutoIncrement(b) => col.auto_increment = b, - ColumnField::Default(s) => col.default_value = s, - ColumnField::Comment(s) => col.comment = s, - } - let new_col = col.clone(); - drop(cols); - // A rebuild sets each widget to the value the model - // already has, which is no edit. - if prev == new_col { + if self.draft.borrow().table.name == text { return; } + self.draft.borrow_mut().table.name = text; self.recompute_dirty_state(fx); } + StructureTabInput::ColumnEdited { index, field } => self.edit_column(index, field, fx), StructureTabInput::AddColumn => { - let new_col = DraftColumn { - original: None, - name: { - let mut seq = self.next_column_seq.borrow_mut(); - let name = format!("column_{}", *seq); - *seq += 1; - name - }, - data_type: default_type_for(&self.driver_id), - nullable: true, - primary_key: false, - auto_increment: false, - default_value: None, - comment: None, - }; - self.columns.borrow_mut().push(new_col); + let name = format!("column_{}", self.next_column_seq); + self.next_column_seq += 1; + let column_type = self.ddl().map_or("text", |ddl| ddl.default_column_type()); + self.draft + .borrow_mut() + .columns + .push(DraftColumn::new(name, DraftType::Typed(column_type.to_owned()))); self.recompute_dirty_state(fx); fx.input(StructureTabInput::Refresh); } StructureTabInput::RemoveColumn(index) => { { - let mut cols = self.columns.borrow_mut(); - if index >= cols.len() { + let mut draft = self.draft.borrow_mut(); + if index >= draft.columns.len() { return; } - cols.remove(index); + draft.columns.remove(index); } + // The rows after it move up one. + self.invalid_defaults = std::mem::take(&mut self.invalid_defaults) + .into_iter() + .filter(|(row, _)| *row != index) + .map(|(row, invalid)| (if row > index { row - 1 } else { row }, invalid)) + .collect(); self.recompute_dirty_state(fx); fx.input(StructureTabInput::Refresh); } StructureTabInput::AddIndex(index) => { - self.indexes.borrow_mut().push(index); + self.draft.borrow_mut().indexes.push(index); self.recompute_dirty_state(fx); fx.input(StructureTabInput::Refresh); } - StructureTabInput::RemoveIndex(idx_pos) => { + StructureTabInput::RemoveIndex(position) => { { - let mut idxs = self.indexes.borrow_mut(); - if idx_pos >= idxs.len() { + let mut draft = self.draft.borrow_mut(); + if position >= draft.indexes.len() { return; } - idxs.remove(idx_pos); + draft.indexes.remove(position); } self.recompute_dirty_state(fx); fx.input(StructureTabInput::Refresh); } StructureTabInput::AddForeignKey(fk) => { - self.foreign_keys.borrow_mut().push(fk); + self.draft.borrow_mut().foreign_keys.push(fk); self.recompute_dirty_state(fx); fx.input(StructureTabInput::Refresh); } - StructureTabInput::RemoveForeignKey(idx_pos) => { + StructureTabInput::RemoveForeignKey(position) => { { - let mut fks = self.foreign_keys.borrow_mut(); - if idx_pos >= fks.len() { + let mut draft = self.draft.borrow_mut(); + if position >= draft.foreign_keys.len() { return; } - fks.remove(idx_pos); + draft.foreign_keys.remove(position); } self.recompute_dirty_state(fx); fx.input(StructureTabInput::Refresh); } StructureTabInput::Save => self.save(fx), StructureTabInput::Discard => { - // Snapshot+diff model: Discard = current state ← - // original snapshot. New mode clears everything since - // the snapshot itself is empty. - let mode = *self.mode.borrow(); - if matches!(mode, StructureMode::New) { - self.columns.borrow_mut().clear(); - self.indexes.borrow_mut().clear(); - self.foreign_keys.borrow_mut().clear(); - } else { - *self.table_name.borrow_mut() = self.original_table_name.borrow().clone(); - *self.columns.borrow_mut() = self - .original_columns - .borrow() - .iter() - .cloned() - .map(DraftColumn::from_definition) - .collect(); - *self.indexes.borrow_mut() = self.original_indexes.borrow().clone(); - *self.foreign_keys.borrow_mut() = self.original_fks.borrow().clone(); - } + // New mode clears everything, since a new table starts + // empty. + let restored = match (self.mode, &self.definition) { + (StructureMode::Edit, Some(definition)) => TableDraft::from_definition(definition), + _ => { + let mut draft = TableDraft::new(self.table_ref()); + draft.table.name = self.draft.borrow().table.name.clone(); + draft + } + }; + *self.draft.borrow_mut() = restored; + self.invalid_defaults.clear(); self.recompute_dirty_state(fx); fx.input(StructureTabInput::Refresh); } StructureTabInput::DropTableRequested => { + let table = match &self.definition { + Some(definition) => definition.table.clone(), + None => self.table_ref(), + }; fx.output(StructureTabOutput::DropTableRequested { - schema: self.schema.clone(), - table: self.table_name.borrow().clone(), + schema: table.schema, + table: table.name, }); } StructureTabInput::SaveCompleted { new_table_name } => self.save_completed(new_table_name, fx), @@ -980,71 +998,139 @@ impl StructureModel { } } - fn save(&self, fx: &mut StructureEffects) { - let table = self.table_name.borrow().clone(); - let mode = *self.mode.borrow(); - let columns = self.columns.borrow().clone(); - if let Err(message) = validate_save(&table, &columns, mode) { - fx.output(StructureTabOutput::ShowToast(message)); + fn edit_column(&mut self, index: usize, field: ColumnField, fx: &mut StructureEffects) { + let Some(previous) = self.draft.borrow().columns.get(index).cloned() else { + return; + }; + let mut column = previous.clone(); + match field { + ColumnField::Name(name) => column.name = name, + ColumnField::Type(text) => { + // Retyping the declaration keeps the catalogue's type, + // which is never parsed again. + column.column_type = match column.original.as_ref() { + Some(original) if DraftType::Catalog(original.info.column_type.clone()).text() == text => { + DraftType::Catalog(original.info.column_type.clone()) + } + _ => DraftType::Typed(text), + }; + } + ColumnField::Nullable(nullable) => column.nullable = nullable, + ColumnField::PrimaryKey(primary_key) => { + column.primary_key = primary_key; + // Every engine numbers only a key column, and the switch + // only greys out, so the model drops it here. + if !primary_key { + column.auto_increment = false; + } + } + ColumnField::AutoIncrement(auto_increment) => column.auto_increment = auto_increment, + ColumnField::Default(text) => match self.parse_default(&column, &text) { + Ok(default) => { + self.invalid_defaults.remove(&index); + column.default = default; + } + Err(error) => { + self.invalid_defaults.insert(index, (text, error)); + } + }, + ColumnField::Comment(comment) => column.comment = comment, + } + // A rebuild sets each widget to the value the model already has, + // which is no edit. + if column == previous { + self.refresh_buttons(self.pending().change_count()); + self.regenerate_sql_preview(&self.pending()); + return; + } + if let Some(slot) = self.draft.borrow_mut().columns.get_mut(index) { + *slot = column; + } + self.recompute_dirty_state(fx); + } + + /// The default the user typed, as the engine's parser reads it. The + /// text the row showed for the loaded default gives that default + /// back, so seeing it again is no edit. + fn parse_default(&self, column: &DraftColumn, text: &str) -> Result { + if let Some(original) = column.original.as_ref().filter(|original| { + let loaded = DraftColumn::from_definition((*original).clone()); + self.default_text(&loaded) == text + }) { + return Ok(original.info.default.clone()); + } + let Some(dialect) = self.dialect.as_deref() else { + return Err(crate::i18n::gettext("Connect to a database to check the default.")); + }; + let column_type = self + .default_column_type(column) + .unwrap_or_else(|| tablepro_core::column::ColumnType::untyped_text(column.column_type.text())); + parse_default(dialect, text, &column_type).map_err(|error| error.to_string()) + } + + fn save(&mut self, fx: &mut StructureEffects) { + let pending = self.pending(); + if let Err(problem) = pending.draft.validate() { + fx.output(StructureTabOutput::ShowToast(draft_problem_text(&problem))); return; } - let ops = self.current_diff_ops(); - match self.materialize(&ops) { - Some(Ok(statements)) if !statements.is_empty() => { + if !self.invalid_defaults.is_empty() { + fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext( + "Fix the highlighted fields first.", + ))); + return; + } + if !pending.has_changes() { + fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext("Nothing to save."))); + return; + } + let Some(ddl) = self.ddl() else { + fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext( + "Connect to a database first.", + ))); + return; + }; + // Planned here only to refuse early in the user's words: the + // window plans again with the connection that runs the save. + match pending.plan(ddl) { + Ok(_) => { + self.tracker.set_pending(Some(pending)); self.save_button.set_sensitive(false); self.discard_button.set_sensitive(false); - fx.output(StructureTabOutput::ExecuteTransaction { statements }); - } - Some(Ok(_)) => { - fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext("Nothing to save."))); - } - None => { - fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext( - "Connect to a database first.", - ))); - } - Some(Err(BuildDdlError::SqliteNotSupported(detail))) => { - fx.output(StructureTabOutput::ShowAlert { - title: crate::i18n::gettext("Cannot save"), - body: crate::i18n::gettext_f("SQLite doesn't support: {detail}", &[("detail", detail)]), - }); - } - Some(Err(e)) => { - fx.output(StructureTabOutput::ShowAlert { - title: crate::i18n::gettext("Cannot save"), - body: format!("{e}"), - }); + fx.output(StructureTabOutput::SaveRequested); } + Err(error) => fx.output(StructureTabOutput::ShowAlert { + title: crate::i18n::gettext("Cannot save"), + body: build_error_text(&error), + }), } } fn save_completed(&mut self, new_table_name: Option, fx: &mut StructureEffects) { if let Some(name) = new_table_name { - *self.mode.borrow_mut() = StructureMode::Edit; - *self.table_name.borrow_mut() = name.clone(); - *self.original_table_name.borrow_mut() = name.clone(); + self.mode = StructureMode::Edit; + self.draft.borrow_mut().table.name = name.clone(); self.name_entry.set_text(&name); self.drop_button.set_visible(true); self.name_row.set_visible(false); } - if !matches!(*self.mode.borrow(), StructureMode::Edit) { + if self.mode != StructureMode::Edit { return; } - // The diff against the pre-save snapshot would show the changes + // The diff against the pre-save definition would show the changes // just committed as still pending until the read lands, and a // close in that window would offer to save them again. So the // dirty state goes to zero now, and so does a CREATE TABLE left // in the preview by a draft that just became the table. self.tracker.clear(); self.refresh_buttons(0); - self.regenerate_sql_preview_from(&[]); - let mut last = self.last_dirty.borrow_mut(); - if *last { - *last = false; + self.sql_buffer + .set_text(&crate::i18n::gettext("-- No pending changes.")); + if self.last_dirty { + self.last_dirty = false; fx.output(StructureTabOutput::DirtyChanged(false)); } - drop(last); - self.refetching.set(true); + self.refetching = true; self.definition_shown = false; self.fetch_definition(); } @@ -1079,43 +1165,6 @@ fn append_add_button(list: >k::ListBox, label: &str, on_activate: impl Fn() + list.append(&row); } -/// The first reason the model cannot be saved, in the user's words. -fn validate_save(table_name: &str, columns: &[DraftColumn], mode: StructureMode) -> Result<(), String> { - if matches!(mode, StructureMode::New) && table_name.trim().is_empty() { - return Err(crate::i18n::gettext("Table name is required.")); - } - // In Edit mode too: removing every column would otherwise save as a - // statement that drops them all. - if columns.is_empty() { - return Err(crate::i18n::gettext("At least one column is required.")); - } - let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); - for col in columns { - if col.name.trim().is_empty() { - return Err(crate::i18n::gettext("Every column needs a name.")); - } - if !seen.insert(col.name.as_str()) { - return Err(crate::i18n::gettext_f( - "Duplicate column name: {name}", - &[("name", &col.name)], - )); - } - if col.data_type.trim().is_empty() { - return Err(crate::i18n::gettext_f( - "Column {name} needs a type.", - &[("name", &col.name)], - )); - } - if col.primary_key && col.nullable { - return Err(crate::i18n::gettext_f( - "Primary key columns must be NOT NULL: {name}", - &[("name", &col.name)], - )); - } - } - Ok(()) -} - fn apply_sql_scheme(buffer: &sourceview5::Buffer) { let scheme_name = if adw::StyleManager::default().is_dark() { "Adwaita-dark" diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index c80bb43d92..acde3e553f 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -1,15 +1,20 @@ use std::time::Duration; +use std::sync::Arc; + use gtk4::subclass::prelude::ObjectSubclassIsExt; use tablepro_core::column::{ColumnDefault, ColumnType}; -use tablepro_core::ddl::{ColumnAttributes, ColumnDefinition, TableDefinition}; +use tablepro_core::ddl::{ + ColumnAttributes, ColumnDefinition, ColumnKeyRoles, InboundForeignKey, PrimaryKeyConstraint, TableDefinition, +}; +use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; -use tablepro_core::{ColumnInfo, DriverError, LossPhase}; +use tablepro_core::{ColumnInfo, DriverError, LossPhase, ServerInfo}; use tablepro_session::fakes::FakeEstablish; use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; use super::*; -use crate::test_support::{FakeSession, drain_main_context, first_descendant_of_type, wait_until}; +use crate::test_support::{FakeSession, descendants, drain_main_context, first_descendant_of_type, wait_until}; const WAIT: Duration = Duration::from_secs(10); @@ -34,7 +39,6 @@ impl Harness { schema: None, table: "users".to_owned(), mode, - driver_id: "postgres".to_owned(), session: fake.session.clone(), tasks: runtime.tasks(), }); @@ -65,8 +69,9 @@ impl Harness { .as_ref() .map(|model| { model - .columns + .draft .borrow() + .columns .iter() .map(|column| column.name.clone()) .collect() @@ -85,6 +90,25 @@ impl Drop for Harness { } } +fn postgres() -> Arc { + Arc::new(drivers_postgres::PgDialect::new(&ServerInfo::new( + "PostgreSQL", + "18.0", + 18, + 0, + 0, + ))) +} + +/// A script whose connection speaks PostgreSQL, so the tab has a DDL +/// dialect to plan with. +fn postgres_script() -> ConnectionScript { + ConnectionScript { + dialect: postgres(), + ..ConnectionScript::default() + } +} + fn definition(column: &str) -> TableDefinition { TableDefinition { table: TableRef::unqualified("users"), @@ -169,7 +193,7 @@ fn structure_tab_freed_after_drop() { #[gtk4::test] fn a_draft_takes_its_dialect_from_the_session() { - let harness = Harness::open(StructureMode::New, ConnectionScript::default(), []); + let harness = Harness::open(StructureMode::New, postgres_script(), []); harness.tab().send(StructureTabInput::AddColumn); drain_main_context(); @@ -301,3 +325,161 @@ fn a_failed_read_after_a_save_shows_the_error() { wait_until(WAIT, || harness.page().as_deref() == Some("error")) .expect("a definition from before the save stayed editable"); } + +fn named_widget(tab: &StructureTab, name: &str) -> gtk::Widget { + descendants(tab) + .into_iter() + .find(|widget| widget.widget_name() == name) + .unwrap_or_else(|| panic!("no widget named {name}")) +} + +fn integer_column(name: &str, primary_key: bool) -> ColumnDefinition { + let column_type = postgres().parse_type("integer").expect("a PostgreSQL type"); + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type, + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) +} + +#[gtk4::test] +fn clickhouse_key_column_rename_is_insensitive_with_reason() { + let clickhouse: Arc = Arc::new(drivers_clickhouse::ClickHouseDialect::new(&ServerInfo::new( + "ClickHouse", + "25.8", + 25, + 8, + 0, + ))); + let mut key = definition("id"); + key.columns[0].info.column_type = clickhouse.parse_type("UInt64").expect("a ClickHouse type"); + key.columns[0].attributes.key_roles = ColumnKeyRoles { + sorting_key: true, + partition_key: false, + }; + let mut label = definition("label").columns.remove(0); + label.info.column_type = clickhouse.parse_type("String").expect("a ClickHouse type"); + key.columns.push(label); + let mut script = ConnectionScript { + dialect: clickhouse, + ..ConnectionScript::default() + }; + script.fetch_table_definition.always(FakeReply::ok(key)); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + drain_main_context(); + + let key_name = named_widget(harness.tab(), "col-name-0"); + let plain_name = named_widget(harness.tab(), "col-name-1"); + + assert!(!key_name.is_sensitive(), "a sorting key column could be renamed"); + assert!( + key_name + .tooltip_text() + .is_some_and(|tip| tip.contains("sorting or partition key")), + "{:?}", + key_name.tooltip_text() + ); + assert!(plain_name.is_sensitive()); + assert!(!named_widget(harness.tab(), "col-remove-0").is_sensitive()); +} + +#[gtk4::test] +fn referenced_primary_key_switch_is_insensitive_with_foreign_key_names() { + let users = TableDefinition { + table: TableRef::unqualified("users"), + columns: vec![integer_column("id", true), integer_column("score", false)], + primary_key: Some(PrimaryKeyConstraint { + name: Some("users_pkey".to_owned()), + columns: vec!["id".to_owned()], + }), + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: vec![InboundForeignKey { + name: "orders_user_fk".to_owned(), + table: TableRef::unqualified("orders"), + columns: vec!["user_id".to_owned()], + referenced_columns: vec!["id".to_owned()], + }], + source: None, + }; + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(users)); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + drain_main_context(); + + for name in ["col-pk-0", "col-pk-1"] { + let switch = named_widget(harness.tab(), name); + assert!(!switch.is_sensitive(), "{name} could move a referenced key"); + assert!( + switch.tooltip_text().is_some_and(|tip| tip.contains("orders_user_fk")), + "{name}: {:?}", + switch.tooltip_text() + ); + } + assert!(!named_widget(harness.tab(), "col-remove-0").is_sensitive()); + assert!(named_widget(harness.tab(), "col-remove-1").is_sensitive()); +} + +#[gtk4::test] +fn an_edit_previews_the_plan_and_saves_through_the_tracker() { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(TableDefinition { + columns: vec![integer_column("id", true)], + ..definition("id") + })); + let tracker = StructureTrackerHandle::default(); + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + let tab = StructureTab::new(StructureTabInit { + tracker: tracker.clone(), + schema: None, + table: "users".to_owned(), + mode: StructureMode::Edit, + session: fake.session.clone(), + tasks: runtime.tasks(), + }); + wait_until(WAIT, || { + tab.imp().inner_stack.visible_child_name().as_deref() == Some("editor") + }) + .expect("the structure"); + let saves = std::rc::Rc::new(std::cell::Cell::new(0)); + let counter = saves.clone(); + tab.connect_save_requested(move |_| counter.set(counter.get() + 1)); + + tab.send(StructureTabInput::ColumnEdited { + index: 0, + field: ColumnField::Default("42".to_owned()), + }); + let buffer = sql_buffer(&tab); + let preview = buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string(); + tab.imp().save_button.emit_clicked(); + + assert_eq!(preview, "ALTER TABLE \"users\" ALTER COLUMN \"id\" SET DEFAULT 42;"); + assert_eq!(saves.get(), 1); + let pending = tracker.begin_save().expect("the change is pending"); + assert_eq!(pending.changes.altered_columns.len(), 1); + + tab.send(StructureTabInput::ColumnEdited { + index: 0, + field: ColumnField::Default("1 +".to_owned()), + }); + assert!( + !tab.imp().save_button.is_sensitive(), + "a default that does not parse can be saved" + ); + drop(tab); + drain_main_context(); + runtime.shutdown(); +} diff --git a/linux/crates/app/src/ui/structure_tab_dialogs.rs b/linux/crates/app/src/ui/structure_tab_dialogs.rs index 317cf4295a..1b28d6cd1f 100644 --- a/linux/crates/app/src/ui/structure_tab_dialogs.rs +++ b/linux/crates/app/src/ui/structure_tab_dialogs.rs @@ -12,9 +12,8 @@ use gtk4 as gtk; use libadwaita as adw; use libadwaita::prelude::*; -use tablepro_core::ddl::ReferentialAction; +use tablepro_core::ddl::{DraftColumn, ReferentialAction}; use tablepro_core::meta::IndexKey; -use tablepro_core::sql_ddl::DraftColumn; use tablepro_core::{ForeignKeyInfo, IndexInfo}; use super::structure_tab::{StructureTabInput, StructureTabOutput, StructureTabSender}; @@ -176,13 +175,14 @@ pub(super) fn present_index_dialog(parent: >k::Widget, columns: &[DraftColumn] dialog.present(Some(parent)); } +/// `fk_actions` are the engine's, from its DDL dialect, so the combos +/// offer nothing the engine would refuse. pub(super) fn present_fk_dialog( parent: >k::Widget, columns: &[DraftColumn], - driver_id: &str, + fk_actions: &'static [ReferentialAction], sender: StructureTabSender, ) { - let fk_actions = tablepro_core::sql_ddl::supported_fk_actions(driver_id); let action_labels = action_labels(fk_actions); let (dialog, body, submit_btn) = build_form_dialog(&crate::i18n::gettext("Add Foreign Key"), &crate::i18n::gettext("Add")); @@ -386,7 +386,8 @@ mod tests { #[test] fn combo_rows_decode_to_the_actions_they_are_labelled_with() { - let mssql = tablepro_core::sql_ddl::supported_fk_actions("mssql"); + use tablepro_core::ddl::DdlDialect; + let mssql = drivers_mssql::MssqlDialect::new(16).referential_actions(); let labels = action_labels(mssql); diff --git a/linux/crates/core/src/ddl/build_ddl_error.rs b/linux/crates/core/src/ddl/build_ddl_error.rs new file mode 100644 index 0000000000..4e520f7635 --- /dev/null +++ b/linux/crates/core/src/ddl/build_ddl_error.rs @@ -0,0 +1,34 @@ +use thiserror::Error; + +use super::{DdlOperation, DraftProblem, ReferentialAction, RefusalReason}; +use crate::dialect::LiteralError; +use crate::sql_syntax::TypeSyntaxError; + +/// Why a structure change could not be written as SQL. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum BuildDdlError { + #[error(transparent)] + Draft(#[from] DraftProblem), + #[error("{0} is not supported by this engine")] + Unsupported(DdlOperation), + #[error("column {column}: {operation} is refused because {reason}")] + Refused { + column: String, + operation: DdlOperation, + reason: RefusalReason, + }, + #[error("column {column} has an invalid type: {error}")] + InvalidType { column: String, error: TypeSyntaxError }, + #[error("column {column} has a default with no SQL form: {error}")] + Literal { column: String, error: LiteralError }, + /// An existing column whose type the catalogue did not spell, so no + /// statement can restate it. + #[error("the catalogue gave no type declaration for column {0}")] + MissingCatalogDeclaration(String), + #[error("{} is not a referential action this engine accepts", .0.as_sql())] + ReferentialAction(ReferentialAction), + /// A table this engine keeps no CREATE statement for, where only the + /// server's own text would recreate it faithfully. + #[error("the server gave no CREATE statement for this table")] + NoTableSource, +} diff --git a/linux/crates/core/src/ddl/changed_attributes.rs b/linux/crates/core/src/ddl/changed_attributes.rs new file mode 100644 index 0000000000..7b9265cc9e --- /dev/null +++ b/linux/crates/core/src/ddl/changed_attributes.rs @@ -0,0 +1,44 @@ +use super::{ColumnDefinition, DraftColumn, DraftType}; + +/// What an edit changed about a column it kept. The name is not here: +/// a rename is its own change, so a rename alone alters nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct ChangedAttributes { + pub column_type: bool, + pub nullability: bool, + pub default: bool, + pub primary_key: bool, + pub auto_increment: bool, + pub comment: bool, +} + +impl ChangedAttributes { + pub fn between(original: &ColumnDefinition, draft: &DraftColumn) -> Self { + let info = &original.info; + let column_type = match &draft.column_type { + DraftType::Catalog(column_type) => *column_type != info.column_type, + // Typed text equal to the declaration is the same type: the + // user retyped what was there. + DraftType::Typed(text) => { + info.column_type + .catalog + .as_ref() + .map(|catalog| catalog.declaration.as_sql()) + != Some(text.trim()) + } + }; + let comment = info.comment.as_deref().filter(|text| !text.is_empty()) != draft.comment_text(); + Self { + column_type, + nullability: info.nullable != draft.nullable, + default: info.default != draft.default, + primary_key: info.primary_key != draft.primary_key, + auto_increment: info.is_auto_increment != draft.auto_increment, + comment, + } + } + + pub fn any(&self) -> bool { + self.column_type || self.nullability || self.default || self.primary_key || self.auto_increment || self.comment + } +} diff --git a/linux/crates/core/src/ddl/column_alteration.rs b/linux/crates/core/src/ddl/column_alteration.rs new file mode 100644 index 0000000000..dcc9da3b53 --- /dev/null +++ b/linux/crates/core/src/ddl/column_alteration.rs @@ -0,0 +1,10 @@ +use super::{ChangedAttributes, ColumnDefinition, DraftColumn}; + +/// A kept column whose definition changed. The draft carries the name +/// the column has once its rename, if any, has run. +#[derive(Debug, Clone, PartialEq)] +pub struct ColumnAlteration { + pub original: ColumnDefinition, + pub draft: DraftColumn, + pub changed: ChangedAttributes, +} diff --git a/linux/crates/core/src/ddl/column_capabilities.rs b/linux/crates/core/src/ddl/column_capabilities.rs new file mode 100644 index 0000000000..94b396c676 --- /dev/null +++ b/linux/crates/core/src/ddl/column_capabilities.rs @@ -0,0 +1,101 @@ +use super::{ColumnDefinition, DdlCapabilities, IdentityStyle, Permission, RefusalReason, TableDefinition}; + +/// Which changes one existing column takes, each with the reason when +/// it does not. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ColumnCapabilities { + pub rename: Permission, + pub drop: Permission, + pub alter_type: Permission, + pub alter_nullability: Permission, + pub alter_default: Permission, + pub alter_primary_key: Permission, + pub alter_auto_increment: Permission, +} + +impl ColumnCapabilities { + /// What every engine refuses on top of its own capabilities: a + /// restatement that would drop a generated expression or an + /// attribute this build cannot restate, a default on a numbered + /// column, and anything foreign keys elsewhere depend on. + pub fn standard(capabilities: &DdlCapabilities, table: &TableDefinition, column: &ColumnDefinition) -> Self { + let attributes = &column.attributes; + let engine = |allowed: bool| Permission::unless(allowed, RefusalReason::EngineUnsupported); + let not_generated = || { + Permission::unless( + !column.info.is_generated && attributes.generated.is_none(), + RefusalReason::GeneratedColumn, + ) + }; + let modeled = || match attributes.unmodeled.first() { + Some(attribute) => Permission::Refused(RefusalReason::UnmodeledAttribute(attribute.name.clone())), + None => Permission::Allowed, + }; + let not_numbered = || { + Permission::unless( + attributes.identity == IdentityStyle::None && !column.info.is_auto_increment, + RefusalReason::IdentityColumn, + ) + }; + let referencing: Vec = table + .inbound_foreign_keys + .iter() + .filter(|key| key.referenced_columns.iter().any(|name| name == column.name())) + .map(|key| key.name.clone()) + .collect(); + Self { + rename: engine(capabilities.rename_column), + drop: engine(capabilities.drop_column).and(|| { + Permission::unless( + referencing.is_empty(), + RefusalReason::ReferencedByForeignKeys(referencing), + ) + }), + alter_type: engine(capabilities.alter_column_type).and(not_generated).and(modeled), + alter_nullability: engine(capabilities.alter_column_nullability) + .and(not_generated) + .and(modeled), + alter_default: engine(capabilities.alter_column_default) + .and(not_generated) + .and(not_numbered) + .and(modeled), + alter_primary_key: primary_key_permission(capabilities, table), + alter_auto_increment: engine(capabilities.alter_auto_increment) + .and(not_generated) + .and(modeled), + } + } +} + +/// Whether the table's primary key can move. A foreign key elsewhere +/// that points at the key holds its constraint in place: PostgreSQL +/// needs CASCADE to drop it, and MySQL, SQL Server and SQLite refuse +/// the change or leave the reference dangling. +pub fn primary_key_permission(capabilities: &DdlCapabilities, table: &TableDefinition) -> Permission { + let key_columns: Vec<&str> = match &table.primary_key { + Some(key) => key.columns.iter().map(String::as_str).collect(), + None => table + .columns + .iter() + .filter(|column| column.info.primary_key) + .map(ColumnDefinition::name) + .collect(), + }; + let referencing: Vec = table + .inbound_foreign_keys + .iter() + .filter(|key| { + key.referenced_columns + .iter() + .any(|name| key_columns.contains(&name.as_str())) + }) + .map(|key| key.name.clone()) + .collect(); + // The references come first: they hold the key on every engine, and + // naming them tells the user what to change. + Permission::unless( + referencing.is_empty(), + RefusalReason::ReferencedByForeignKeys(referencing), + ) + .and(|| Permission::unless(capabilities.alter_primary_key, RefusalReason::EngineUnsupported)) +} diff --git a/linux/crates/core/src/ddl/column_rename.rs b/linux/crates/core/src/ddl/column_rename.rs new file mode 100644 index 0000000000..b72d3d4e7f --- /dev/null +++ b/linux/crates/core/src/ddl/column_rename.rs @@ -0,0 +1,6 @@ +/// A kept column that changed its name. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ColumnRename { + pub from: String, + pub to: String, +} diff --git a/linux/crates/core/src/ddl/ddl_capabilities.rs b/linux/crates/core/src/ddl/ddl_capabilities.rs index 7b37f45839..b77eff89cf 100644 --- a/linux/crates/core/src/ddl/ddl_capabilities.rs +++ b/linux/crates/core/src/ddl/ddl_capabilities.rs @@ -21,5 +21,8 @@ pub struct DdlCapabilities { pub partial_indexes: bool, pub add_foreign_key: bool, pub drop_foreign_key: bool, + /// Whether a column can carry a comment. SQLite drops one from the + /// CREATE TABLE text, so it has nowhere to keep it. + pub column_comments: bool, pub drop_table_cascade: bool, } diff --git a/linux/crates/core/src/ddl/ddl_dialect.rs b/linux/crates/core/src/ddl/ddl_dialect.rs index 06adf9aea4..bd55de2dea 100644 --- a/linux/crates/core/src/ddl/ddl_dialect.rs +++ b/linux/crates/core/src/ddl/ddl_dialect.rs @@ -1,111 +1,64 @@ -use super::{ColumnDefinition, DdlCapabilities}; +use super::{ + BuildDdlError, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlOperation, DdlPlan, DropTableOptions, + ReferentialAction, TableChanges, TableDefinition, TableDraft, +}; use crate::dialect::SqlDialect; use crate::meta::TableRef; -/// How one engine spells structure changes. +/// How one engine plans structure changes. /// /// Reached through `SqlDialect::ddl`, which is `None` for an engine -/// whose structure the app does not edit. The planning methods land -/// with the per-engine DDL plans; the capabilities are here first so -/// the UI can gate on them through the same accessor. +/// whose structure the app does not edit. A plan is built against the +/// table's definition as one read returned it, so a statement that +/// restates a column, renames a key or drops a default by name has +/// everything it needs from the same moment. /// -/// The statement methods take the `SqlDialect` this came from, which -/// quotes the names. Their bodies are the standard spelling, and an -/// engine overrides one only where its grammar differs. +/// Every plan refuses what `capabilities` and `column_capabilities` +/// refuse, with the same reasons the structure tab shows. pub trait DdlDialect: Send + Sync + std::fmt::Debug { fn capabilities(&self) -> &DdlCapabilities; - fn rename_table(&self, dialect: &dyn SqlDialect, table: &TableRef, new_name: &str) -> String { - format!( - "ALTER TABLE {} RENAME TO {}", - dialect.qualify(table), - dialect.quote_identifier(new_name) - ) - } + /// The dialect that quotes, binds and parses for this engine. + fn sql_dialect(&self) -> &dyn SqlDialect; - fn rename_column(&self, dialect: &dyn SqlDialect, table: &TableRef, column: &str, new_name: &str) -> String { - format!( - "ALTER TABLE {} RENAME COLUMN {} TO {}", - dialect.qualify(table), - dialect.quote_identifier(column), - dialect.quote_identifier(new_name) - ) + fn column_capabilities(&self, table: &TableDefinition, column: &ColumnDefinition) -> ColumnCapabilities { + ColumnCapabilities::standard(self.capabilities(), table, column) } - /// The statement that removes a column's default, or `None` where - /// there is nothing to remove. The standard spelling needs only the - /// column's name; an engine that keeps the default as a named object - /// finds that name in the catalogue's definition. - /// - /// `column` is the name the column has when the statement runs, - /// which a rename earlier in the same change makes differ from the - /// catalogue's. - fn drop_column_default( - &self, - dialect: &dyn SqlDialect, - table: &TableRef, - column: &str, - _original: &ColumnDefinition, - ) -> Option { - Some(format!( - "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT", - dialect.qualify(table), - dialect.quote_identifier(column) - )) - } -} + /// Types offered in the structure tab, most common first. Each one + /// parses through `SqlDialect::parse_type`, and any other type can + /// still be typed. + fn type_suggestions(&self) -> &'static [&'static str]; -#[cfg(test)] -mod tests { - use super::*; - use crate::column::{ColumnDefault, ColumnInfo, ColumnKind, ColumnType, ReadForm, TextLength}; - use crate::ddl::ColumnAttributes; - use crate::dialect::TestDialect; + /// The type a column added in the structure tab starts with. + fn default_column_type(&self) -> &'static str; - fn column(name: &str) -> ColumnDefinition { - ColumnDefinition::new( - ColumnInfo { - name: name.to_owned(), - column_type: ColumnType { - name: "text".to_owned(), - kind: ColumnKind::Text { - length: TextLength::Unbounded, - }, - catalog: None, - dynamic_storage: false, - read_form: ReadForm::Native, - }, - nullable: true, - primary_key: false, - is_auto_increment: false, - is_generated: false, - default: ColumnDefault::None, - comment: None, - }, - ColumnAttributes::default(), - ) + /// The actions a foreign key may take, in the order the UI offers + /// them. The first is the SQL default. + fn referential_actions(&self) -> &'static [ReferentialAction] { + &ReferentialAction::ALL } - #[test] - fn the_standard_spelling_quotes_through_the_dialect() { - let dialect = TestDialect::exact(); - let Some(ddl) = dialect.ddl() else { - panic!("the test dialect has no DDL"); - }; - let table = TableRef::new(Some("app".to_owned()), "or\"ders"); + fn plan_create_table(&self, draft: &TableDraft) -> Result; + + fn plan_alter_table(&self, original: &TableDefinition, changes: &TableChanges) -> Result; + + fn plan_drop_table(&self, table: &TableRef, options: DropTableOptions) -> Result { + if options.cascade && !self.capabilities().drop_table_cascade { + return Err(BuildDdlError::Unsupported(DdlOperation::DropTableCascade)); + } + let if_exists = if options.if_exists { "IF EXISTS " } else { "" }; + let cascade = if options.cascade { " CASCADE" } else { "" }; + Ok(DdlPlan::from_sql([format!( + "DROP TABLE {if_exists}{}{cascade}", + self.sql_dialect().qualify(table) + )])) + } - assert_eq!( - ddl.rename_table(&dialect, &table, "new"), - "ALTER TABLE \"app\".\"or\"\"ders\" RENAME TO \"new\"" - ); - assert_eq!( - ddl.rename_column(&dialect, &table, "a", "b\"c"), - "ALTER TABLE \"app\".\"or\"\"ders\" RENAME COLUMN \"a\" TO \"b\"\"c\"" - ); - assert_eq!( - ddl.drop_column_default(&dialect, &table, "renamed", &column("original")), - Some("ALTER TABLE \"app\".\"or\"\"ders\" ALTER COLUMN \"renamed\" DROP DEFAULT".to_owned()), - "the drop names the column as it is now" - ); + /// Statements that create the table as it is defined now: the + /// server's own text where the engine keeps one, otherwise written + /// from the definition. + fn create_table_script(&self, definition: &TableDefinition) -> Result { + self.plan_create_table(&TableDraft::from_definition(definition)) } } diff --git a/linux/crates/core/src/ddl/ddl_operation.rs b/linux/crates/core/src/ddl/ddl_operation.rs new file mode 100644 index 0000000000..3397b5327e --- /dev/null +++ b/linux/crates/core/src/ddl/ddl_operation.rs @@ -0,0 +1,55 @@ +use std::fmt; + +/// One kind of structure change, named in an error that refuses it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DdlOperation { + CreateTable, + DropTable, + DropTableCascade, + RenameTable, + AddColumn, + /// SQLite refuses `ADD COLUMN ... NOT NULL` without a default, + /// because the rows already there would have no value. + AddRequiredColumnWithoutDefault, + DropColumn, + RenameColumn, + AlterColumnType, + AlterColumnNullability, + AlterColumnDefault, + AlterPrimaryKey, + AlterAutoIncrement, + SetColumnComment, + CreateIndex, + IndexExpressionKey, + PartialIndex, + DropIndex, + AddForeignKey, + DropForeignKey, +} + +impl fmt::Display for DdlOperation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::CreateTable => "creating a table", + Self::DropTable => "dropping a table", + Self::DropTableCascade => "dropping a table with its dependents", + Self::RenameTable => "renaming a table", + Self::AddColumn => "adding a column", + Self::AddRequiredColumnWithoutDefault => "adding a NOT NULL column without a default", + Self::DropColumn => "dropping a column", + Self::RenameColumn => "renaming a column", + Self::AlterColumnType => "changing a column's type", + Self::AlterColumnNullability => "changing whether a column takes NULL", + Self::AlterColumnDefault => "changing a column's default", + Self::AlterPrimaryKey => "changing the primary key", + Self::AlterAutoIncrement => "changing whether a column numbers itself", + Self::SetColumnComment => "setting a column comment", + Self::CreateIndex => "creating an index", + Self::IndexExpressionKey => "an index on an expression", + Self::PartialIndex => "a partial index", + Self::DropIndex => "dropping an index", + Self::AddForeignKey => "adding a foreign key", + Self::DropForeignKey => "dropping a foreign key", + }) + } +} diff --git a/linux/crates/core/src/ddl/ddl_plan.rs b/linux/crates/core/src/ddl/ddl_plan.rs new file mode 100644 index 0000000000..e4454bc3b3 --- /dev/null +++ b/linux/crates/core/src/ddl/ddl_plan.rs @@ -0,0 +1,81 @@ +use crate::foreign_key_mode::ForeignKeyMode; +use crate::meta::TableRef; +use crate::statement::{GuardedStatement, Statement, StatementBuilder}; +use crate::write_batch::{WriteBatch, WriteKind}; + +/// The statements a structure change runs, in order, and whether foreign +/// keys stay on while they do. +#[derive(Debug, Clone, PartialEq)] +pub struct DdlPlan { + pub statements: Vec, + pub foreign_keys: ForeignKeyMode, +} + +impl DdlPlan { + /// SQL the engine's DDL dialect wrote, with every name quoted by it + /// and nothing to bind. + pub fn from_sql(statements: impl IntoIterator) -> Self { + Self { + statements: statements + .into_iter() + .map(|sql| { + let mut builder = StatementBuilder::new(); + builder.push_sql(&sql); + builder.finish() + }) + .collect(), + foreign_keys: ForeignKeyMode::Enforced, + } + } + + pub fn is_empty(&self) -> bool { + self.statements.is_empty() + } + + pub fn len(&self) -> usize { + self.statements.len() + } + + pub fn sql(&self) -> Vec<&str> { + self.statements.iter().map(Statement::sql).collect() + } + + /// The plan as the user reads it before Save: each statement ended + /// and set apart. + pub fn preview(&self) -> String { + self.statements + .iter() + .map(|statement| format!("{};", statement.sql())) + .collect::>() + .join("\n\n") + } + + /// Schema statements have no row count to prove. + pub fn into_steps(self) -> Vec { + self.statements.into_iter().map(GuardedStatement::unguarded).collect() + } + + /// `table` is the table as it is named before the plan runs. + pub fn into_write_batch(self, table: TableRef) -> WriteBatch { + let foreign_keys = self.foreign_keys.clone(); + WriteBatch::new(WriteKind::SchemaChanges, table, self.into_steps(), foreign_keys) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_plan_becomes_an_unguarded_schema_write() { + let plan = DdlPlan::from_sql(["ALTER TABLE t ADD COLUMN a text".to_owned(), "DROP INDEX i".to_owned()]); + + assert_eq!(plan.preview(), "ALTER TABLE t ADD COLUMN a text;\n\nDROP INDEX i;"); + let batch = plan.into_write_batch(TableRef::unqualified("t")); + + assert_eq!(batch.kind, WriteKind::SchemaChanges); + assert!(batch.foreign_keys.is_enforced()); + assert!(batch.steps.iter().all(|step| step.statement.params().is_empty())); + assert_eq!(batch.steps[1].statement.sql(), "DROP INDEX i"); + } +} diff --git a/linux/crates/core/src/ddl/ddl_sql.rs b/linux/crates/core/src/ddl/ddl_sql.rs new file mode 100644 index 0000000000..2d9b4acbbd --- /dev/null +++ b/linux/crates/core/src/ddl/ddl_sql.rs @@ -0,0 +1,143 @@ +//! The pieces of DDL most engines spell alike, each written through the +//! engine's own dialect. + +use super::BuildDdlError; +use crate::column::{ColumnDefault, ColumnType}; +use crate::dialect::SqlDialect; +use crate::foreign_key_info::ForeignKeyInfo; +use crate::meta::{IndexInfo, IndexKey, TableRef}; + +/// A literal default through the dialect's literal, and an expression as +/// the catalogue or the engine's parser gave it. `None` for no default. +pub fn default_sql( + dialect: &dyn SqlDialect, + column: &str, + default: &ColumnDefault, + column_type: &ColumnType, +) -> Result, BuildDdlError> { + match default { + ColumnDefault::None => Ok(None), + ColumnDefault::Literal(value) => { + dialect + .literal(value, column_type) + .map(Some) + .map_err(|error| BuildDdlError::Literal { + column: column.to_owned(), + error, + }) + } + ColumnDefault::Expression(expression) => Ok(Some(expression.as_sql().to_owned())), + } +} + +/// `expression` inside one pair of parentheses. The catalogue hands back +/// SQL Server and MySQL defaults already wrapped, and wrapping them again +/// would grow the text on every round trip. +pub fn parenthesized(expression: &str) -> String { + let text = expression.trim(); + match wrapped_once(text) { + true => text.to_owned(), + false => format!("({text})"), + } +} + +fn wrapped_once(text: &str) -> bool { + if !text.starts_with('(') || !text.ends_with(')') { + return false; + } + let mut depth = 0usize; + let mut quote: Option = None; + for (index, character) in text.char_indices() { + match (quote, character) { + (Some(open), _) if character == open => quote = None, + (Some(_), _) => {} + (None, '\'' | '"' | '`' | '[') => quote = Some(if character == '[' { ']' } else { character }), + (None, '(') => depth += 1, + (None, ')') => { + depth = depth.saturating_sub(1); + if depth == 0 && index + 1 < text.len() { + return false; + } + } + (None, _) => {} + } + } + depth == 0 +} + +pub fn column_list(dialect: &dyn SqlDialect, columns: &[String]) -> String { + columns + .iter() + .map(|column| dialect.quote_identifier(column)) + .collect::>() + .join(", ") +} + +/// An index's keys inside its parentheses. An expression key came from +/// the catalogue, and goes in parentheses of its own, which every engine +/// with expression keys reads as one key. +pub fn index_keys_sql(dialect: &dyn SqlDialect, index: &IndexInfo) -> String { + index + .keys + .iter() + .map(|key| { + let direction = if key.descending() { " DESC" } else { "" }; + match key { + IndexKey::Column { name, .. } => format!("{}{direction}", dialect.quote_identifier(name)), + IndexKey::Expression { sql, .. } => format!("{}{direction}", parenthesized(sql)), + } + }) + .collect::>() + .join(", ") +} + +/// `CREATE [UNIQUE] INDEX name ON table (keys) [WHERE predicate]`, with +/// the table qualified and the index name bare, which is how PostgreSQL, +/// MySQL and SQL Server read it. +pub fn create_index_sql(dialect: &dyn SqlDialect, table: &TableRef, index: &IndexInfo) -> String { + let unique = if index.unique { "UNIQUE " } else { "" }; + let mut sql = format!( + "CREATE {unique}INDEX {} ON {} ({})", + dialect.quote_identifier(&index.name), + dialect.qualify(table), + index_keys_sql(dialect, index) + ); + if let Some(predicate) = &index.predicate { + sql.push_str(&format!(" WHERE {predicate}")); + } + sql +} + +/// `CONSTRAINT name FOREIGN KEY (...) REFERENCES table (...)` and its +/// actions. A key with no schema of its own references the table's. +pub fn foreign_key_sql(dialect: &dyn SqlDialect, table: &TableRef, key: &ForeignKeyInfo) -> String { + let schema = key.ref_schema.clone().or_else(|| table.schema.clone()); + let referenced = TableRef::new(schema, key.ref_table.clone()); + let mut sql = format!( + "CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})", + dialect.quote_identifier(&key.name), + column_list(dialect, &key.columns), + dialect.qualify(&referenced), + column_list(dialect, &key.ref_columns) + ); + for (clause, action) in [("ON DELETE", key.on_delete), ("ON UPDATE", key.on_update)] { + if let Some(action) = action.filter(|action| !action.is_default()) { + sql.push_str(&format!(" {clause} {}", action.as_sql())); + } + } + sql +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_expression_is_wrapped_once() { + assert_eq!(parenthesized("getdate()"), "(getdate())"); + assert_eq!(parenthesized("(getdate())"), "(getdate())"); + assert_eq!(parenthesized("(a) + (b)"), "((a) + (b))"); + assert_eq!(parenthesized("(N')(')"), "(N')(')"); + assert_eq!(parenthesized(" now() "), "(now())"); + } +} diff --git a/linux/crates/core/src/ddl/default_text.rs b/linux/crates/core/src/ddl/default_text.rs new file mode 100644 index 0000000000..3a2a83696a --- /dev/null +++ b/linux/crates/core/src/ddl/default_text.rs @@ -0,0 +1,49 @@ +use crate::column::{ColumnDefault, ColumnType}; +use crate::dialect::SqlDialect; +use crate::edit::parse_literal_text; +use crate::sql_syntax::{ExpressionSyntaxError, LiteralToken, MAX_EXPRESSION_LEN, parse_expression}; +use crate::value::Value; + +/// A default as the structure tab shows it: SQL the user could type +/// back in. +pub fn default_text(dialect: &dyn SqlDialect, default: &ColumnDefault, column_type: &ColumnType) -> String { + match default { + ColumnDefault::None => String::new(), + ColumnDefault::Literal(value) => dialect + .literal(value, column_type) + .ok() + .or_else(|| crate::export::value_to_text(value)) + .unwrap_or_default(), + ColumnDefault::Expression(expression) => expression.as_sql().to_owned(), + } +} + +/// SQL the user typed as a default, accepted only when the engine's +/// parser reads it as one expression. A plain literal becomes a value, +/// so the plan writes it back in the dialect's own literal form, as it +/// does a literal the catalogue reported. +pub fn parse_default( + dialect: &dyn SqlDialect, + text: &str, + column_type: &ColumnType, +) -> Result { + let text = text.trim(); + if text.is_empty() { + return Ok(ColumnDefault::None); + } + let expression = dialect.parse_expression(text)?; + let literal = parse_expression(dialect.grammar(), text, MAX_EXPRESSION_LEN) + .ok() + .and_then(|parsed| parsed.literal); + let literal_text = match literal { + None => return Ok(ColumnDefault::Expression(expression)), + Some(LiteralToken::Null) => return Ok(ColumnDefault::None), + Some(LiteralToken::Text(text)) => text, + Some(LiteralToken::Number(number)) => number.to_string(), + Some(LiteralToken::Boolean(flag)) => flag.to_string(), + }; + match parse_literal_text(&literal_text, column_type) { + Ok(Value::Null) | Err(_) => Ok(ColumnDefault::Expression(expression)), + Ok(value) => Ok(ColumnDefault::Literal(value)), + } +} diff --git a/linux/crates/core/src/ddl/diff_table.rs b/linux/crates/core/src/ddl/diff_table.rs new file mode 100644 index 0000000000..eb68f63f4a --- /dev/null +++ b/linux/crates/core/src/ddl/diff_table.rs @@ -0,0 +1,112 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; + +use super::{ + ChangedAttributes, ColumnAlteration, ColumnRename, PrimaryKeyChange, PrimaryKeyConstraint, TableChanges, + TableDefinition, TableDraft, +}; + +/// What the draft changed about the table it started from. +/// +/// Columns are matched by the name they had when the table was read, so +/// a rename is a rename and not a drop and an add. Indexes and foreign +/// keys are matched whole: one that changed is dropped and added again, +/// since no engine alters either in place. +pub fn diff_table(original: &TableDefinition, draft: &TableDraft) -> TableChanges { + let name = draft.table.name.trim(); + let renamed_table = (!name.is_empty() && name != original.table.name).then(|| name.to_owned()); + + let kept: HashSet<&str> = draft + .columns + .iter() + .filter_map(|column| column.original_name()) + .collect(); + let dropped_columns = original + .columns + .iter() + .filter(|column| !kept.contains(column.name())) + .cloned() + .collect(); + let mut renamed_columns = Vec::new(); + let mut altered_columns = Vec::new(); + let mut added_columns = Vec::new(); + for column in &draft.columns { + let Some(before) = &column.original else { + added_columns.push(column.clone()); + continue; + }; + if before.name() != column.name { + renamed_columns.push(ColumnRename { + from: before.name().to_owned(), + to: column.name.clone(), + }); + } + let changed = ChangedAttributes::between(before, column); + if changed.any() { + altered_columns.push(ColumnAlteration { + original: before.clone(), + draft: column.clone(), + changed, + }); + } + } + + let secondary = |indexes: &[crate::meta::IndexInfo], other: &[crate::meta::IndexInfo]| { + indexes + .iter() + .filter(|index| !index.primary && !other.contains(index)) + .cloned() + .collect::>() + }; + let missing = |keys: &[crate::ForeignKeyInfo], other: &[crate::ForeignKeyInfo]| { + keys.iter() + .filter(|key| !other.contains(key)) + .cloned() + .collect::>() + }; + + TableChanges { + renamed_table, + dropped_foreign_keys: missing(&original.foreign_keys, &draft.foreign_keys), + dropped_indexes: secondary(&original.indexes, &draft.indexes), + dropped_columns, + primary_key: primary_key_change(original, draft, &renamed_columns), + renamed_columns, + altered_columns, + added_columns, + added_indexes: secondary(&draft.indexes, &original.indexes), + added_foreign_keys: missing(&draft.foreign_keys, &original.foreign_keys), + } +} + +/// The key compared as a set of columns under their new names: listing +/// the same columns in another order is not a change the user made. +fn primary_key_change( + original: &TableDefinition, + draft: &TableDraft, + renames: &[ColumnRename], +) -> Option { + let renamed: HashMap<&str, &str> = renames + .iter() + .map(|rename| (rename.from.as_str(), rename.to.as_str())) + .collect(); + let from = original.primary_key.clone().or_else(|| { + let columns: Vec = original + .columns + .iter() + .filter(|column| column.info.primary_key) + .map(|column| column.name().to_owned()) + .collect(); + (!columns.is_empty()).then_some(PrimaryKeyConstraint { name: None, columns }) + }); + let before: BTreeSet<&str> = from + .iter() + .flat_map(|key| key.columns.iter()) + .map(|name| renamed.get(name.as_str()).copied().unwrap_or(name)) + .collect(); + let to = draft.primary_key(); + let after: BTreeSet<&str> = to.iter().copied().collect(); + (before != after).then(|| PrimaryKeyChange { + from, + to: to.into_iter().map(str::to_owned).collect(), + }) +} diff --git a/linux/crates/core/src/ddl/draft_column.rs b/linux/crates/core/src/ddl/draft_column.rs new file mode 100644 index 0000000000..f76d1baaed --- /dev/null +++ b/linux/crates/core/src/ddl/draft_column.rs @@ -0,0 +1,55 @@ +use super::{ColumnDefinition, DraftType}; +use crate::column::ColumnDefault; + +/// A column as the user is editing it. +#[derive(Debug, Clone, PartialEq)] +pub struct DraftColumn { + /// The column this draft started from, `None` for one the user added. + pub original: Option, + pub name: String, + pub column_type: DraftType, + pub nullable: bool, + pub primary_key: bool, + pub auto_increment: bool, + pub default: ColumnDefault, + pub comment: Option, +} + +impl DraftColumn { + pub fn new(name: impl Into, column_type: DraftType) -> Self { + Self { + original: None, + name: name.into(), + column_type, + nullable: true, + primary_key: false, + auto_increment: false, + default: ColumnDefault::None, + comment: None, + } + } + + pub fn from_definition(definition: ColumnDefinition) -> Self { + let info = &definition.info; + Self { + name: info.name.clone(), + column_type: DraftType::Catalog(info.column_type.clone()), + nullable: info.nullable, + primary_key: info.primary_key, + auto_increment: info.is_auto_increment, + default: info.default.clone(), + comment: info.comment.clone(), + original: Some(definition), + } + } + + pub fn original_name(&self) -> Option<&str> { + self.original.as_ref().map(ColumnDefinition::name) + } + + /// An empty entry is the user saying "no comment", not a comment that + /// happens to be empty. + pub fn comment_text(&self) -> Option<&str> { + self.comment.as_deref().filter(|text| !text.is_empty()) + } +} diff --git a/linux/crates/core/src/ddl/draft_problem.rs b/linux/crates/core/src/ddl/draft_problem.rs new file mode 100644 index 0000000000..a005cf5c67 --- /dev/null +++ b/linux/crates/core/src/ddl/draft_problem.rs @@ -0,0 +1,31 @@ +use thiserror::Error; + +/// Why a table draft cannot be saved as it stands, found before any SQL +/// is written. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Error)] +pub enum DraftProblem { + #[error("the table name is empty")] + EmptyTableName, + #[error("the table has no columns")] + NoColumns, + #[error("a column has no name")] + EmptyColumnName, + #[error("two columns are named {0}")] + DuplicateColumnName(String), + #[error("column {0} has no type")] + EmptyColumnType(String), + #[error("primary key column {0} takes NULL")] + NullablePrimaryKey(String), + /// An index or foreign key the draft keeps names a column it no + /// longer has. + #[error("column {column} is gone but {} still use it", used_by.join(", "))] + ColumnInUse { column: String, used_by: Vec }, + #[error("an index has no name")] + EmptyIndexName, + #[error("index {0} has no keys")] + IndexWithoutKeys(String), + #[error("a foreign key has no name")] + EmptyForeignKeyName, + #[error("foreign key {0} pairs a different number of columns on each side")] + ForeignKeyColumnMismatch(String), +} diff --git a/linux/crates/core/src/ddl/draft_type.rs b/linux/crates/core/src/ddl/draft_type.rs new file mode 100644 index 0000000000..2755c4effd --- /dev/null +++ b/linux/crates/core/src/ddl/draft_type.rs @@ -0,0 +1,69 @@ +use super::BuildDdlError; +use crate::column::ColumnType; +use crate::dialect::SqlDialect; + +/// A draft column's type: the catalogue's own, or text the user typed. +/// +/// The catalogue's declaration is spliced as the server gave it and is +/// never parsed again. Typed text goes through the engine's parser when +/// it is planned, and is spliced exactly as typed once it passes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DraftType { + Catalog(ColumnType), + Typed(String), +} + +/// A draft type ready to splice, with what the engine says it holds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedType { + pub sql: String, + pub column_type: ColumnType, +} + +impl DraftType { + /// The text the user sees and edits. Empty for a catalogue type the + /// server did not spell. + pub fn text(&self) -> &str { + match self { + Self::Catalog(column_type) => column_type + .catalog + .as_ref() + .map_or("", |catalog| catalog.declaration.as_sql()), + Self::Typed(text) => text.trim(), + } + } + + /// What the column holds, for writing a default in its literal form. + /// A catalogue type needs no declaration for that. + pub fn column_type(&self, dialect: &dyn SqlDialect, column: &str) -> Result { + match self { + Self::Catalog(column_type) => Ok(column_type.clone()), + Self::Typed(_) => self.resolve(dialect, column).map(|resolved| resolved.column_type), + } + } + + pub fn resolve(&self, dialect: &dyn SqlDialect, column: &str) -> Result { + match self { + Self::Catalog(column_type) => { + let catalog = column_type + .catalog + .as_ref() + .ok_or_else(|| BuildDdlError::MissingCatalogDeclaration(column.to_owned()))?; + Ok(ResolvedType { + sql: catalog.declaration.as_sql().to_owned(), + column_type: column_type.clone(), + }) + } + Self::Typed(text) => { + let column_type = dialect.parse_type(text).map_err(|error| BuildDdlError::InvalidType { + column: column.to_owned(), + error, + })?; + Ok(ResolvedType { + sql: text.trim().to_owned(), + column_type, + }) + } + } + } +} diff --git a/linux/crates/core/src/ddl/drop_table_options.rs b/linux/crates/core/src/ddl/drop_table_options.rs new file mode 100644 index 0000000000..6727c6ad5e --- /dev/null +++ b/linux/crates/core/src/ddl/drop_table_options.rs @@ -0,0 +1,8 @@ +/// How a table is dropped. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct DropTableOptions { + pub if_exists: bool, + /// Drop the objects that depend on the table too. Only an engine + /// with `drop_table_cascade` takes it. + pub cascade: bool, +} diff --git a/linux/crates/core/src/ddl/mod.rs b/linux/crates/core/src/ddl/mod.rs index eb750664c3..0d5a7f7c15 100644 --- a/linux/crates/core/src/ddl/mod.rs +++ b/linux/crates/core/src/ddl/mod.rs @@ -1,35 +1,77 @@ -//! The model of a table that structure changes are planned against. +//! Structure changes: the definition of a table, a draft of it the user +//! edits, the changes between the two, and the per-engine plans that +//! turn them into DDL. +mod build_ddl_error; +mod changed_attributes; mod collation_name; +mod column_alteration; mod column_attributes; +mod column_capabilities; mod column_definition; mod column_key_roles; +mod column_rename; mod column_visibility; mod ddl_capabilities; mod ddl_dialect; +mod ddl_operation; +mod ddl_plan; +pub mod ddl_sql; +mod default_text; +mod diff_table; +mod draft_column; +mod draft_problem; +mod draft_type; +mod drop_table_options; mod generated_column; mod identity_style; mod inbound_foreign_key; +mod permission; +mod primary_key_change; mod primary_key_constraint; mod referential_action; +mod refusal_reason; mod schema_object_source; +mod table_changes; mod table_definition; +mod table_draft; mod table_source; mod unmodeled_attribute; +#[cfg(test)] +mod tests; + +pub use build_ddl_error::BuildDdlError; +pub use changed_attributes::ChangedAttributes; pub use collation_name::CollationName; +pub use column_alteration::ColumnAlteration; pub use column_attributes::ColumnAttributes; +pub use column_capabilities::{ColumnCapabilities, primary_key_permission}; pub use column_definition::ColumnDefinition; pub use column_key_roles::ColumnKeyRoles; +pub use column_rename::ColumnRename; pub use column_visibility::ColumnVisibility; pub use ddl_capabilities::DdlCapabilities; pub use ddl_dialect::DdlDialect; +pub use ddl_operation::DdlOperation; +pub use ddl_plan::DdlPlan; +pub use default_text::{default_text, parse_default}; +pub use diff_table::diff_table; +pub use draft_column::DraftColumn; +pub use draft_problem::DraftProblem; +pub use draft_type::{DraftType, ResolvedType}; +pub use drop_table_options::DropTableOptions; pub use generated_column::{GeneratedColumn, GeneratedStorage}; pub use identity_style::IdentityStyle; pub use inbound_foreign_key::InboundForeignKey; +pub use permission::Permission; +pub use primary_key_change::PrimaryKeyChange; pub use primary_key_constraint::PrimaryKeyConstraint; pub use referential_action::ReferentialAction; +pub use refusal_reason::RefusalReason; pub use schema_object_source::{SchemaObjectKind, SchemaObjectSource}; +pub use table_changes::TableChanges; pub use table_definition::TableDefinition; +pub use table_draft::TableDraft; pub use table_source::TableSource; pub use unmodeled_attribute::UnmodeledAttribute; diff --git a/linux/crates/core/src/ddl/permission.rs b/linux/crates/core/src/ddl/permission.rs new file mode 100644 index 0000000000..f3a5f08fb9 --- /dev/null +++ b/linux/crates/core/src/ddl/permission.rs @@ -0,0 +1,38 @@ +use super::RefusalReason; + +/// Whether one change to one column can be made. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Permission { + Allowed, + Refused(RefusalReason), +} + +impl Permission { + pub fn is_allowed(&self) -> bool { + matches!(self, Self::Allowed) + } + + pub fn refusal(&self) -> Option<&RefusalReason> { + match self { + Self::Allowed => None, + Self::Refused(reason) => Some(reason), + } + } + + /// `Allowed` when `allowed` holds, otherwise refused for `reason`. + pub fn unless(allowed: bool, reason: RefusalReason) -> Self { + match allowed { + true => Self::Allowed, + false => Self::Refused(reason), + } + } + + /// The first refusal of the two. A change needs every check to pass, + /// and the user is told the first one that did not. + pub fn and(self, then: impl FnOnce() -> Permission) -> Self { + match self { + Self::Allowed => then(), + refused => refused, + } + } +} diff --git a/linux/crates/core/src/ddl/primary_key_change.rs b/linux/crates/core/src/ddl/primary_key_change.rs new file mode 100644 index 0000000000..8eb82143ca --- /dev/null +++ b/linux/crates/core/src/ddl/primary_key_change.rs @@ -0,0 +1,11 @@ +use super::PrimaryKeyConstraint; + +/// The primary key moving to other columns, or away. +/// +/// `to` names the columns as they are after every rename, and is empty +/// when the table is left without a key. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PrimaryKeyChange { + pub from: Option, + pub to: Vec, +} diff --git a/linux/crates/core/src/ddl/refusal_reason.rs b/linux/crates/core/src/ddl/refusal_reason.rs new file mode 100644 index 0000000000..0fe1ca8bb5 --- /dev/null +++ b/linux/crates/core/src/ddl/refusal_reason.rs @@ -0,0 +1,44 @@ +use std::fmt; + +/// Why one change to one column cannot be made. +/// +/// The structure tab shows this beside the control it turns off, so the +/// user learns why before Save rather than from the server afterwards. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum RefusalReason { + /// The engine has no statement for it. + EngineUnsupported, + /// A ClickHouse sorting or partition key column, which only a table + /// rebuild can change. + KeyColumn, + /// The server computes the column, and restating it would drop the + /// expression. + GeneratedColumn, + /// The server numbers the column, which rules out a default. + IdentityColumn, + /// The column carries an attribute this build does not model, which a + /// restated definition would drop. + UnmodeledAttribute(String), + /// Indexes or foreign keys still use the column. + ColumnInUse(Vec), + /// Foreign keys on other tables point at the column or at the primary + /// key it belongs to. + ReferencedByForeignKeys(Vec), + /// The catalogue gave no definition the change could restate. + UnreadableDefinition, +} + +impl fmt::Display for RefusalReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EngineUnsupported => f.write_str("the engine cannot make this change"), + Self::KeyColumn => f.write_str("the column is part of the table's sorting or partition key"), + Self::GeneratedColumn => f.write_str("the server computes the column"), + Self::IdentityColumn => f.write_str("the server numbers the column"), + Self::UnmodeledAttribute(name) => write!(f, "the column has an attribute this app cannot restate: {name}"), + Self::ColumnInUse(users) => write!(f, "still used by {}", users.join(", ")), + Self::ReferencedByForeignKeys(keys) => write!(f, "referenced by foreign keys {}", keys.join(", ")), + Self::UnreadableDefinition => f.write_str("the catalogue gave no definition to restate"), + } + } +} diff --git a/linux/crates/core/src/ddl/table_changes.rs b/linux/crates/core/src/ddl/table_changes.rs new file mode 100644 index 0000000000..701f6ff7fc --- /dev/null +++ b/linux/crates/core/src/ddl/table_changes.rs @@ -0,0 +1,176 @@ +use super::{ + BuildDdlError, ColumnAlteration, ColumnDefinition, ColumnRename, DdlDialect, DdlOperation, DraftColumn, Permission, + PrimaryKeyChange, TableDefinition, primary_key_permission, +}; +use crate::foreign_key_info::ForeignKeyInfo; +use crate::meta::{IndexInfo, IndexKey, TableRef}; + +/// Everything an edit changed about an existing table, which a DDL +/// dialect turns into its own statements in its own order. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct TableChanges { + pub renamed_table: Option, + pub dropped_foreign_keys: Vec, + pub dropped_indexes: Vec, + pub dropped_columns: Vec, + pub renamed_columns: Vec, + pub altered_columns: Vec, + pub added_columns: Vec, + /// Follows from the columns' primary-key flags, so it is not a change + /// of its own in `change_count`. + pub primary_key: Option, + pub added_indexes: Vec, + pub added_foreign_keys: Vec, +} + +impl TableChanges { + pub fn is_empty(&self) -> bool { + self.change_count() == 0 && self.primary_key.is_none() + } + + /// One per edit the user made: a column renamed and retyped is two. + pub fn change_count(&self) -> usize { + usize::from(self.renamed_table.is_some()) + + self.dropped_foreign_keys.len() + + self.dropped_indexes.len() + + self.dropped_columns.len() + + self.renamed_columns.len() + + self.altered_columns.len() + + self.added_columns.len() + + self.added_indexes.len() + + self.added_foreign_keys.len() + } + + /// The table as it is named once the changes have run. + pub fn target(&self, original: &TableRef) -> TableRef { + match &self.renamed_table { + Some(name) => TableRef::new(original.schema.clone(), name.clone()), + None => original.clone(), + } + } + + /// Refuse a change the dialect says it cannot make, before any SQL + /// is written. The structure tab gates its controls on the same + /// answers, so this only fires for a caller that skipped them. + pub fn check(&self, ddl: &dyn DdlDialect, original: &TableDefinition) -> Result<(), BuildDdlError> { + let capabilities = ddl.capabilities(); + if self.renamed_table.is_some() { + require(capabilities.rename_table, DdlOperation::RenameTable)?; + } + if !self.dropped_foreign_keys.is_empty() { + require(capabilities.drop_foreign_key, DdlOperation::DropForeignKey)?; + } + if !self.dropped_indexes.is_empty() { + require(capabilities.drop_index, DdlOperation::DropIndex)?; + } + for column in &self.dropped_columns { + let permission = ddl.column_capabilities(original, column).drop; + permit(permission, column.name(), DdlOperation::DropColumn)?; + } + for rename in &self.renamed_columns { + if let Some(column) = original.column(&rename.from) { + let permission = ddl.column_capabilities(original, column).rename; + permit(permission, &rename.from, DdlOperation::RenameColumn)?; + } + } + for alteration in &self.altered_columns { + let permissions = ddl.column_capabilities(original, &alteration.original); + let name = alteration.original.name(); + let changed = alteration.changed; + let checks = [ + ( + changed.column_type, + permissions.alter_type, + DdlOperation::AlterColumnType, + ), + ( + changed.nullability, + permissions.alter_nullability, + DdlOperation::AlterColumnNullability, + ), + ( + changed.default, + permissions.alter_default, + DdlOperation::AlterColumnDefault, + ), + ( + changed.auto_increment, + permissions.alter_auto_increment, + DdlOperation::AlterAutoIncrement, + ), + ]; + for (changed, permission, operation) in checks { + if changed { + permit(permission, name, operation)?; + } + } + if changed.comment { + require(capabilities.column_comments, DdlOperation::SetColumnComment)?; + } + } + for column in &self.added_columns { + require(capabilities.add_column, DdlOperation::AddColumn)?; + if column.comment_text().is_some() { + require(capabilities.column_comments, DdlOperation::SetColumnComment)?; + } + } + if let Some(change) = &self.primary_key { + let column = change + .to + .first() + .or_else(|| change.from.as_ref().and_then(|key| key.columns.first())) + .map_or("", String::as_str); + permit( + primary_key_permission(capabilities, original), + column, + DdlOperation::AlterPrimaryKey, + )?; + } + check_new_indexes(ddl, &self.added_indexes)?; + if !self.added_foreign_keys.is_empty() { + require(capabilities.add_foreign_key, DdlOperation::AddForeignKey)?; + } + check_actions(ddl, &self.added_foreign_keys) + } +} + +pub(super) fn require(allowed: bool, operation: DdlOperation) -> Result<(), BuildDdlError> { + match allowed { + true => Ok(()), + false => Err(BuildDdlError::Unsupported(operation)), + } +} + +fn permit(permission: Permission, column: &str, operation: DdlOperation) -> Result<(), BuildDdlError> { + match permission { + Permission::Allowed => Ok(()), + Permission::Refused(reason) => Err(BuildDdlError::Refused { + column: column.to_owned(), + operation, + reason, + }), + } +} + +pub(super) fn check_new_indexes(ddl: &dyn DdlDialect, indexes: &[IndexInfo]) -> Result<(), BuildDdlError> { + let capabilities = ddl.capabilities(); + for index in indexes.iter().filter(|index| !index.primary) { + require(capabilities.create_index, DdlOperation::CreateIndex)?; + if index.keys.iter().any(|key| matches!(key, IndexKey::Expression { .. })) { + require(capabilities.expression_index_keys, DdlOperation::IndexExpressionKey)?; + } + if index.predicate.is_some() { + require(capabilities.partial_indexes, DdlOperation::PartialIndex)?; + } + } + Ok(()) +} + +pub(super) fn check_actions(ddl: &dyn DdlDialect, keys: &[ForeignKeyInfo]) -> Result<(), BuildDdlError> { + let accepted = ddl.referential_actions(); + keys.iter() + .flat_map(|key| [key.on_delete, key.on_update]) + .flatten() + .find(|action| !accepted.contains(action)) + .map_or(Ok(()), |action| Err(BuildDdlError::ReferentialAction(action))) +} diff --git a/linux/crates/core/src/ddl/table_draft.rs b/linux/crates/core/src/ddl/table_draft.rs new file mode 100644 index 0000000000..545799af0e --- /dev/null +++ b/linux/crates/core/src/ddl/table_draft.rs @@ -0,0 +1,149 @@ +use std::collections::{BTreeMap, HashSet}; + +use super::table_changes::{check_actions, check_new_indexes, require}; +use super::{BuildDdlError, DdlDialect, DdlOperation, DraftColumn, DraftProblem, TableDefinition}; +use crate::foreign_key_info::ForeignKeyInfo; +use crate::meta::{IndexInfo, TableRef}; + +/// A table as the user is editing it: a new one, or an existing one +/// started from its definition. +#[derive(Debug, Clone, PartialEq)] +pub struct TableDraft { + pub table: TableRef, + pub columns: Vec, + /// The primary key's own index included: it follows the columns' + /// primary-key flags, and no plan restates it. + pub indexes: Vec, + pub foreign_keys: Vec, +} + +impl TableDraft { + pub fn new(table: TableRef) -> Self { + Self { + table, + columns: Vec::new(), + indexes: Vec::new(), + foreign_keys: Vec::new(), + } + } + + pub fn from_definition(definition: &TableDefinition) -> Self { + Self { + table: definition.table.clone(), + columns: definition + .columns + .iter() + .cloned() + .map(DraftColumn::from_definition) + .collect(), + indexes: definition.indexes.clone(), + foreign_keys: definition.foreign_keys.clone(), + } + } + + pub fn column(&self, name: &str) -> Option<&DraftColumn> { + self.columns.iter().find(|column| column.name == name) + } + + /// The primary key's columns, in column order. + pub fn primary_key(&self) -> Vec<&str> { + self.columns + .iter() + .filter(|column| column.primary_key) + .map(|column| column.name.as_str()) + .collect() + } + + /// The first reason the draft cannot be saved. + pub fn validate(&self) -> Result<(), DraftProblem> { + if self.table.name.trim().is_empty() { + return Err(DraftProblem::EmptyTableName); + } + if self.columns.is_empty() { + return Err(DraftProblem::NoColumns); + } + let mut seen: HashSet<&str> = HashSet::new(); + for column in &self.columns { + if column.name.trim().is_empty() { + return Err(DraftProblem::EmptyColumnName); + } + if !seen.insert(column.name.as_str()) { + return Err(DraftProblem::DuplicateColumnName(column.name.clone())); + } + if column.column_type.text().is_empty() { + return Err(DraftProblem::EmptyColumnType(column.name.clone())); + } + if column.primary_key && column.nullable { + return Err(DraftProblem::NullablePrimaryKey(column.name.clone())); + } + } + for index in self.indexes.iter().filter(|index| !index.primary) { + if index.name.trim().is_empty() { + return Err(DraftProblem::EmptyIndexName); + } + if index.keys.is_empty() { + return Err(DraftProblem::IndexWithoutKeys(index.name.clone())); + } + } + for key in &self.foreign_keys { + if key.name.trim().is_empty() { + return Err(DraftProblem::EmptyForeignKeyName); + } + if key.columns.is_empty() || key.columns.len() != key.ref_columns.len() { + return Err(DraftProblem::ForeignKeyColumnMismatch(key.name.clone())); + } + } + self.columns_in_use() + } + + /// Refuse a draft the dialect cannot create, before any SQL is + /// written. Foreign keys are declared inside CREATE TABLE, so an + /// engine that cannot add one to an existing table still takes them. + pub fn check_create(&self, ddl: &dyn DdlDialect) -> Result<(), BuildDdlError> { + let capabilities = ddl.capabilities(); + require(capabilities.create_table, DdlOperation::CreateTable)?; + self.validate()?; + if self.columns.iter().any(|column| column.comment_text().is_some()) { + require(capabilities.column_comments, DdlOperation::SetColumnComment)?; + } + check_new_indexes(ddl, &self.indexes)?; + check_actions(ddl, &self.foreign_keys) + } + + /// A kept index or foreign key may name a column by the name it had + /// when it was read, since a rename carries it along. + fn columns_in_use(&self) -> Result<(), DraftProblem> { + let known: HashSet<&str> = self + .columns + .iter() + .flat_map(|column| std::iter::once(column.name.as_str()).chain(column.original_name())) + .collect(); + let mut missing: BTreeMap<&str, Vec> = BTreeMap::new(); + let index_columns = self.indexes.iter().filter(|index| !index.primary).flat_map(|index| { + index + .keys + .iter() + .filter_map(|key| key.column_name()) + .map(|c| (c, &index.name)) + }); + let key_columns = self + .foreign_keys + .iter() + .flat_map(|key| key.columns.iter().map(|c| (c.as_str(), &key.name))); + for (column, user) in index_columns.chain(key_columns) { + if !known.contains(column) { + let users = missing.entry(column).or_default(); + if !users.contains(user) { + users.push(user.clone()); + } + } + } + match missing.into_iter().next() { + Some((column, used_by)) => Err(DraftProblem::ColumnInUse { + column: column.to_owned(), + used_by, + }), + None => Ok(()), + } + } +} diff --git a/linux/crates/core/src/ddl/tests.rs b/linux/crates/core/src/ddl/tests.rs new file mode 100644 index 0000000000..c3a32928c4 --- /dev/null +++ b/linux/crates/core/src/ddl/tests.rs @@ -0,0 +1,619 @@ +use super::*; +use crate::column::{ + CatalogType, ColumnDefault, ColumnInfo, ColumnKind, ColumnType, IntegerKind, IntegerWidth, ReadForm, SqlExpression, + SqlTypeExpr, TextLength, +}; +use crate::dialect::{SqlDialect, TestDialect}; +use crate::foreign_key_info::ForeignKeyInfo; +use crate::meta::{IndexInfo, IndexKey, TableRef}; +use crate::value::Value; + +fn catalog_type(declaration: &str, kind: ColumnKind) -> ColumnType { + ColumnType { + name: declaration.to_owned(), + kind, + catalog: Some(CatalogType { + declaration: SqlTypeExpr::from_catalog_text(declaration), + cast_target: SqlTypeExpr::from_catalog_text(declaration), + }), + dynamic_storage: false, + read_form: ReadForm::Native, + } +} + +fn integer() -> ColumnType { + catalog_type( + "integer", + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }), + ) +} + +fn text() -> ColumnType { + catalog_type( + "text", + ColumnKind::Text { + length: TextLength::Unbounded, + }, + ) +} + +fn column(name: &str, column_type: ColumnType, primary_key: bool) -> ColumnDefinition { + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type, + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) +} + +fn index(name: &str, columns: &[&str]) -> IndexInfo { + IndexInfo { + name: name.to_owned(), + keys: columns + .iter() + .map(|column| IndexKey::Column { + name: (*column).to_owned(), + descending: false, + }) + .collect(), + unique: false, + primary: false, + predicate: None, + } +} + +fn foreign_key(name: &str, column: &str) -> ForeignKeyInfo { + ForeignKeyInfo { + name: name.to_owned(), + columns: vec![column.to_owned()], + ref_schema: None, + ref_table: "accounts".to_owned(), + ref_columns: vec!["id".to_owned()], + on_delete: Some(ReferentialAction::Cascade), + on_update: None, + } +} + +fn users() -> TableDefinition { + TableDefinition { + table: TableRef::new(Some("app".to_owned()), "users"), + columns: vec![ + column("id", integer(), true), + column("email", text(), false), + column("account_id", integer(), false), + column("legacy", text(), false), + ], + primary_key: Some(PrimaryKeyConstraint { + name: Some("users_pkey".to_owned()), + columns: vec!["id".to_owned()], + }), + indexes: vec![ + IndexInfo { + primary: true, + unique: true, + ..index("users_pkey", &["id"]) + }, + index("by_email", &["email"]), + index("by_legacy", &["legacy"]), + ], + foreign_keys: vec![foreign_key("users_account_fk", "account_id")], + inbound_foreign_keys: Vec::new(), + source: None, + } +} + +fn draft_column<'a>(draft: &'a mut TableDraft, name: &str) -> &'a mut DraftColumn { + draft + .columns + .iter_mut() + .find(|column| column.name == name) + .expect("the column is in the draft") +} + +#[test] +fn diff_table_detects_each_change_kind() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + draft.table.name = "members".to_owned(); + draft.columns.retain(|column| column.name != "legacy"); + draft.indexes.retain(|index| index.name != "by_legacy"); + draft.foreign_keys.clear(); + draft_column(&mut draft, "email").name = "mail".to_owned(); + draft_column(&mut draft, "account_id").nullable = false; + let mut added = DraftColumn::new("nickname", DraftType::Typed("varchar(40)".to_owned())); + added.default = ColumnDefault::Literal(Value::Text("it's".to_owned())); + draft.columns.push(added); + draft.indexes.push(index("by_nickname", &["nickname"])); + draft.foreign_keys.push(foreign_key("users_account_fk2", "account_id")); + + let changes = diff_table(&original, &draft); + + assert_eq!(changes.renamed_table.as_deref(), Some("members")); + assert_eq!( + changes + .dropped_columns + .iter() + .map(ColumnDefinition::name) + .collect::>(), + ["legacy"] + ); + assert_eq!( + changes.renamed_columns, + [ColumnRename { + from: "email".to_owned(), + to: "mail".to_owned() + }] + ); + assert_eq!(changes.altered_columns.len(), 1); + let alteration = &changes.altered_columns[0]; + assert_eq!(alteration.original.name(), "account_id"); + assert!(alteration.changed.nullability && !alteration.changed.column_type && !alteration.changed.default); + assert_eq!( + changes + .added_columns + .iter() + .map(|c| c.name.as_str()) + .collect::>(), + ["nickname"] + ); + assert_eq!( + changes + .dropped_indexes + .iter() + .map(|i| i.name.as_str()) + .collect::>(), + ["by_legacy"] + ); + assert_eq!( + changes + .added_indexes + .iter() + .map(|i| i.name.as_str()) + .collect::>(), + ["by_nickname"] + ); + assert_eq!(changes.dropped_foreign_keys[0].name, "users_account_fk"); + assert_eq!(changes.added_foreign_keys[0].name, "users_account_fk2"); + assert_eq!(changes.primary_key, None, "the key did not move"); + assert_eq!( + changes.target(&original.table), + TableRef::new(Some("app".to_owned()), "members") + ); +} + +#[test] +fn an_unedited_draft_changes_nothing() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + // Retyping the declaration is the same type. + draft_column(&mut draft, "email").column_type = DraftType::Typed(" text ".to_owned()); + // An empty comment is no comment. + draft_column(&mut draft, "legacy").comment = Some(String::new()); + + let changes = diff_table(&original, &draft); + + assert!(changes.is_empty(), "{changes:?}"); + assert_eq!(changes.change_count(), 0); +} + +#[test] +fn a_rename_alone_is_one_change_and_alters_nothing() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + draft_column(&mut draft, "email").name = "mail".to_owned(); + + let changes = diff_table(&original, &draft); + + assert_eq!(changes.change_count(), 1); + assert!(changes.altered_columns.is_empty()); +} + +#[test] +fn change_count_matches_user_visible_edits() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + // A column renamed and retyped is two edits. + let email = draft_column(&mut draft, "email"); + email.name = "mail".to_owned(); + email.column_type = DraftType::Typed("varchar(320)".to_owned()); + // Three attributes of one column are one edit. + let legacy = draft_column(&mut draft, "legacy"); + legacy.nullable = false; + legacy.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("now()")); + legacy.comment = Some("kept for the import".to_owned()); + draft.indexes.retain(|index| index.name != "by_email"); + draft + .columns + .push(DraftColumn::new("nickname", DraftType::Typed("text".to_owned()))); + + let changes = diff_table(&original, &draft); + + assert_eq!(changes.change_count(), 5, "{changes:#?}"); +} + +#[test] +fn moving_the_primary_key_is_a_key_change_under_the_new_names() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + let id = draft_column(&mut draft, "id"); + id.name = "user_id".to_owned(); + let unchanged = diff_table(&original, &draft); + assert_eq!(unchanged.primary_key, None, "a renamed key column is the same key"); + + let account = draft_column(&mut draft, "account_id"); + account.primary_key = true; + account.nullable = false; + let changes = diff_table(&original, &draft); + + let change = changes.primary_key.expect("the key moved"); + assert_eq!(change.to, ["user_id", "account_id"]); + assert_eq!(change.from.and_then(|key| key.name).as_deref(), Some("users_pkey")); +} + +#[test] +fn table_draft_validate_reports_column_in_use() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + draft + .columns + .retain(|column| column.name != "email" && column.name != "account_id"); + + assert_eq!( + draft.validate(), + Err(DraftProblem::ColumnInUse { + column: "account_id".to_owned(), + used_by: vec!["users_account_fk".to_owned()], + }) + ); + + draft.foreign_keys.clear(); + assert_eq!( + draft.validate(), + Err(DraftProblem::ColumnInUse { + column: "email".to_owned(), + used_by: vec!["by_email".to_owned()], + }) + ); + + // A rename carries the index along, so the old name still counts. + let mut renamed = TableDraft::from_definition(&original); + draft_column(&mut renamed, "email").name = "mail".to_owned(); + assert_eq!(renamed.validate(), Ok(())); +} + +#[test] +fn table_draft_validate_reports_the_first_problem() { + let mut draft = TableDraft::new(TableRef::unqualified(" ")); + assert_eq!(draft.validate(), Err(DraftProblem::EmptyTableName)); + draft.table.name = "t".to_owned(); + assert_eq!(draft.validate(), Err(DraftProblem::NoColumns)); + + draft.columns = vec![ + DraftColumn::new("a", DraftType::Typed("int".to_owned())), + DraftColumn::new("a", DraftType::Typed("int".to_owned())), + ]; + assert_eq!(draft.validate(), Err(DraftProblem::DuplicateColumnName("a".to_owned()))); + + draft.columns[1].name = String::new(); + assert_eq!(draft.validate(), Err(DraftProblem::EmptyColumnName)); + + draft.columns[1].name = "b".to_owned(); + draft.columns[1].column_type = DraftType::Typed(" ".to_owned()); + assert_eq!(draft.validate(), Err(DraftProblem::EmptyColumnType("b".to_owned()))); + + draft.columns[1].column_type = DraftType::Typed("int".to_owned()); + draft.columns[0].primary_key = true; + assert_eq!(draft.validate(), Err(DraftProblem::NullablePrimaryKey("a".to_owned()))); + + draft.columns[0].nullable = false; + assert_eq!(draft.validate(), Ok(())); +} + +fn inbound(name: &str, referenced: &str) -> InboundForeignKey { + InboundForeignKey { + name: name.to_owned(), + table: TableRef::new(Some("app".to_owned()), "orders"), + columns: vec!["user_id".to_owned()], + referenced_columns: vec![referenced.to_owned()], + } +} + +fn every_change() -> DdlCapabilities { + DdlCapabilities { + create_table: true, + rename_table: true, + add_column: true, + drop_column: true, + rename_column: true, + alter_column_type: true, + alter_column_nullability: true, + nullability_switch: true, + alter_column_default: true, + alter_primary_key: true, + alter_auto_increment: true, + create_index: true, + drop_index: true, + expression_index_keys: true, + partial_indexes: true, + add_foreign_key: true, + drop_foreign_key: true, + column_comments: true, + drop_table_cascade: true, + } +} + +#[test] +fn a_referenced_primary_key_cannot_move_from_any_column() { + let mut table = users(); + table.inbound_foreign_keys = vec![inbound("orders_user_fk", "id")]; + + for column in &table.columns { + let permissions = ColumnCapabilities::standard(&every_change(), &table, column); + assert_eq!( + permissions.alter_primary_key, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec![ + "orders_user_fk".to_owned() + ])), + "{}", + column.name() + ); + } + let free = ColumnCapabilities::standard(&every_change(), &users(), &users().columns[0]); + assert_eq!(free.alter_primary_key, Permission::Allowed); +} + +#[test] +fn a_referenced_column_cannot_be_dropped() { + let mut table = users(); + table.inbound_foreign_keys = vec![inbound("orders_mail_fk", "email"), inbound("audit_mail_fk", "email")]; + + let email = ColumnCapabilities::standard(&every_change(), &table, &table.columns[1]); + let legacy = ColumnCapabilities::standard(&every_change(), &table, &table.columns[3]); + + assert_eq!( + email.drop, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec![ + "orders_mail_fk".to_owned(), + "audit_mail_fk".to_owned() + ])) + ); + assert_eq!(email.rename, Permission::Allowed); + assert_eq!(legacy.drop, Permission::Allowed); +} + +#[test] +fn generated_numbered_and_unmodeled_columns_refuse_a_restatement() { + let table = users(); + let mut generated = column("total", integer(), false); + generated.attributes.generated = Some(GeneratedColumn { + expression: SqlExpression::from_catalog_text("a + b"), + storage: GeneratedStorage::Stored, + }); + let mut numbered = column("seq", integer(), false); + numbered.attributes.identity = IdentityStyle::GeneratedByDefault; + let mut versioned = column("note", text(), false); + versioned.attributes.unmodeled = vec![UnmodeledAttribute::new("WITHOUT SYSTEM VERSIONING")]; + let mut capabilities = every_change(); + capabilities.alter_column_type = false; + + let generated = ColumnCapabilities::standard(&every_change(), &table, &generated); + let numbered = ColumnCapabilities::standard(&every_change(), &table, &numbered); + let versioned = ColumnCapabilities::standard(&every_change(), &table, &versioned); + let unsupported = ColumnCapabilities::standard(&capabilities, &table, &table.columns[1]); + + assert_eq!( + generated.alter_type, + Permission::Refused(RefusalReason::GeneratedColumn) + ); + assert_eq!(generated.rename, Permission::Allowed); + assert_eq!( + numbered.alter_default, + Permission::Refused(RefusalReason::IdentityColumn) + ); + assert_eq!(numbered.alter_type, Permission::Allowed); + assert_eq!( + versioned.alter_nullability, + Permission::Refused(RefusalReason::UnmodeledAttribute( + "WITHOUT SYSTEM VERSIONING".to_owned() + )) + ); + assert_eq!( + unsupported.alter_type, + Permission::Refused(RefusalReason::EngineUnsupported) + ); +} + +/// A dialect whose plans only report that they were reached, for the +/// checks every plan runs first. +#[derive(Debug)] +struct CheckingDdl { + capabilities: DdlCapabilities, + sql: TestDialect, +} + +impl DdlDialect for CheckingDdl { + fn capabilities(&self) -> &DdlCapabilities { + &self.capabilities + } + + fn sql_dialect(&self) -> &dyn SqlDialect { + &self.sql + } + + fn type_suggestions(&self) -> &'static [&'static str] { + &["integer"] + } + + fn default_column_type(&self) -> &'static str { + "text" + } + + fn referential_actions(&self) -> &'static [ReferentialAction] { + &[ReferentialAction::NoAction, ReferentialAction::SetNull] + } + + fn plan_create_table(&self, draft: &TableDraft) -> Result { + draft.check_create(self)?; + Ok(DdlPlan::from_sql(Vec::new())) + } + + fn plan_alter_table(&self, original: &TableDefinition, changes: &TableChanges) -> Result { + changes.check(self, original)?; + Ok(DdlPlan::from_sql(Vec::new())) + } +} + +fn checking(capabilities: DdlCapabilities) -> CheckingDdl { + CheckingDdl { + capabilities, + sql: TestDialect::exact(), + } +} + +#[test] +fn a_plan_refuses_what_the_capabilities_refuse() { + let mut table = users(); + table.inbound_foreign_keys = vec![inbound("orders_user_fk", "id")]; + let mut draft = TableDraft::from_definition(&table); + draft.columns.retain(|column| column.name != "id"); + let mut no_drop = every_change(); + no_drop.drop_column = false; + + let refused = checking(every_change()).plan_alter_table(&table, &diff_table(&table, &draft)); + let unsupported = checking(no_drop).plan_alter_table(&table, &diff_table(&table, &draft)); + + assert!( + matches!( + &refused, + Err(BuildDdlError::Refused { + column, + operation: DdlOperation::DropColumn, + reason: RefusalReason::ReferencedByForeignKeys(_), + }) if column == "id" + ), + "{refused:?}" + ); + assert_eq!( + unsupported, + Err(BuildDdlError::Refused { + column: "id".to_owned(), + operation: DdlOperation::DropColumn, + reason: RefusalReason::EngineUnsupported, + }) + ); +} + +#[test] +fn a_plan_refuses_an_action_the_engine_does_not_take() { + let table = users(); + let mut draft = TableDraft::from_definition(&table); + draft.foreign_keys[0].on_delete = Some(ReferentialAction::Restrict); + + let outcome = checking(every_change()).plan_alter_table(&table, &diff_table(&table, &draft)); + + assert_eq!( + outcome, + Err(BuildDdlError::ReferentialAction(ReferentialAction::Restrict)) + ); +} + +#[test] +fn a_create_needs_the_capability_and_a_valid_draft() { + let mut no_create = every_change(); + no_create.create_table = false; + let mut draft = TableDraft::new(TableRef::unqualified("t")); + + assert_eq!( + checking(no_create).plan_create_table(&draft), + Err(BuildDdlError::Unsupported(DdlOperation::CreateTable)) + ); + assert_eq!( + checking(every_change()).plan_create_table(&draft), + Err(BuildDdlError::Draft(DraftProblem::NoColumns)) + ); + draft + .columns + .push(DraftColumn::new("a", DraftType::Typed("int".to_owned()))); + assert!(checking(every_change()).plan_create_table(&draft).is_ok()); +} + +#[test] +fn a_default_drop_takes_the_dialect_default() { + let plan = checking(every_change()) + .plan_drop_table( + &TableRef::new(Some("app".to_owned()), "or\"ders"), + DropTableOptions { + if_exists: true, + cascade: true, + }, + ) + .expect("the drop plans"); + let mut no_cascade = every_change(); + no_cascade.drop_table_cascade = false; + + assert_eq!(plan.sql(), ["DROP TABLE IF EXISTS \"app\".\"or\"\"ders\" CASCADE"]); + assert_eq!( + checking(no_cascade).plan_drop_table( + &TableRef::unqualified("t"), + DropTableOptions { + if_exists: false, + cascade: true + } + ), + Err(BuildDdlError::Unsupported(DdlOperation::DropTableCascade)) + ); +} + +#[test] +fn a_typed_type_is_parsed_and_spliced_as_typed() { + let dialect = TestDialect::exact(); + + let resolved = DraftType::Typed(" varchar(20) ".to_owned()).resolve(&dialect, "name"); + let refused = DraftType::Typed("int; DROP TABLE t".to_owned()).resolve(&dialect, "name"); + let missing = DraftType::Catalog(ColumnType::unknown()).resolve(&dialect, "name"); + + assert_eq!(resolved.map(|resolved| resolved.sql), Ok("varchar(20)".to_owned())); + assert!(matches!(refused, Err(BuildDdlError::InvalidType { column, .. }) if column == "name")); + assert_eq!( + missing, + Err(BuildDdlError::MissingCatalogDeclaration("name".to_owned())) + ); +} + +#[test] +fn a_typed_default_becomes_a_value_or_an_expression() { + let dialect = TestDialect::exact(); + + assert_eq!( + parse_default(&dialect, "'it''s'", &text()), + Ok(ColumnDefault::Literal(Value::Text("it's".to_owned()))) + ); + assert_eq!( + parse_default(&dialect, "42", &integer()), + Ok(ColumnDefault::Literal(Value::Int(42))) + ); + assert!(matches!( + parse_default(&dialect, "now()", &text()), + Ok(ColumnDefault::Expression(expression)) if expression.as_sql() == "now()" + )); + assert_eq!(parse_default(&dialect, " ", &text()), Ok(ColumnDefault::None)); + assert!(parse_default(&dialect, "1; DROP TABLE t", &integer()).is_err()); + assert_eq!( + default_text( + &dialect, + &ColumnDefault::Literal(Value::Text("it's".to_owned())), + &text() + ), + "'it''s'" + ); +} diff --git a/linux/crates/core/src/dialect/test_dialect.rs b/linux/crates/core/src/dialect/test_dialect.rs index 054b93f6fd..15cd33942e 100644 --- a/linux/crates/core/src/dialect/test_dialect.rs +++ b/linux/crates/core/src/dialect/test_dialect.rs @@ -1,7 +1,6 @@ use std::sync::Mutex; use crate::column::{ColumnInfo, ColumnType, SqlExpression, SqlTypeExpr}; -use crate::ddl::{DdlCapabilities, DdlDialect}; use crate::meta::{EngineRowId, EngineRowIdPart}; use crate::sql_syntax::{ExpressionSyntaxError, SqlGrammar, TypeSyntaxError, parse_expression, parse_type}; use crate::statement::{BoundParam, ParamType}; @@ -24,20 +23,6 @@ pub struct TestDialect { count_all: &'static str, grammar: SqlGrammar, binds: Mutex>, - ddl: Option, -} - -/// The standard DDL spelling, for the builders that render through -/// `ddl()`. No core test reads its capabilities. -#[derive(Debug, Default)] -struct TestDdl { - capabilities: DdlCapabilities, -} - -impl DdlDialect for TestDdl { - fn capabilities(&self) -> &DdlCapabilities { - &self.capabilities - } } /// How the test dialect spells a marker. @@ -75,7 +60,6 @@ impl Default for TestDialect { count_all: "COUNT(*)", grammar: SqlGrammar::PostgreSql, binds: Mutex::new(Vec::new()), - ddl: Some(TestDdl::default()), } } } @@ -107,11 +91,6 @@ impl TestDialect { } } - /// An engine whose structure the app does not edit. - pub fn without_ddl(self) -> Self { - Self { ddl: None, ..self } - } - pub fn counting_with(self, count_all: &'static str) -> Self { Self { count_all, ..self } } @@ -271,10 +250,6 @@ impl SqlDialect for TestDialect { let parsed = parse_expression(self.grammar, text, crate::sql_syntax::MAX_EXPRESSION_LEN)?; Ok(SqlExpression::from_catalog_text(parsed.text)) } - - fn ddl(&self) -> Option<&dyn DdlDialect> { - self.ddl.as_ref().map(|ddl| ddl as &dyn DdlDialect) - } } fn row_id_type(part: EngineRowIdPart) -> &'static str { diff --git a/linux/crates/core/src/lib.rs b/linux/crates/core/src/lib.rs index 6ba1a714c3..f6d721f09d 100644 --- a/linux/crates/core/src/lib.rs +++ b/linux/crates/core/src/lib.rs @@ -50,7 +50,6 @@ mod server_diagnostics; mod server_info; mod server_name_override; mod session_fate; -pub mod sql_ddl; pub mod sql_syntax; mod ssh_failure; pub mod statement; diff --git a/linux/crates/core/src/sql_ddl.rs b/linux/crates/core/src/sql_ddl.rs deleted file mode 100644 index be2376bcec..0000000000 --- a/linux/crates/core/src/sql_ddl.rs +++ /dev/null @@ -1,2489 +0,0 @@ -//! DDL string builders for CREATE / ALTER / DROP TABLE, CREATE / DROP -//! INDEX, ADD / DROP FOREIGN KEY across MySQL + Postgres + SQLite. -//! -//! Pure SQL-string construction; no async, no I/O. Keyed by -//! `driver_id` until the per-engine DDL dialects replace it; renames -//! and default drops already come from the engine's `DdlDialect`. All -//! identifier quoting goes through the engine dialect's quoting; type -//! names interpolate raw because they're a syntax category, not a -//! string-literal category — the worst case is a driver syntax error, -//! never injection. -//! -//! Statement ordering for a multi-op materialize() is handled by the -//! caller (the StructureChangeTracker). Each builder produces one -//! statement at a time; the caller composes them. - -use thiserror::Error; - -use crate::column::ColumnType; -use crate::ddl::{ColumnDefinition, DdlDialect, ReferentialAction}; -use crate::dialect::SqlDialect; -use crate::foreign_key_info::ForeignKeyInfo; -use crate::meta::{IndexInfo, IndexKey, TableRef}; - -#[derive(Debug, Error)] -pub enum BuildDdlError { - #[error("table name is empty")] - EmptyTableName, - - #[error("at least one column is required")] - NoColumns, - - #[error("column name is empty")] - EmptyColumnName, - - #[error("column type is empty")] - EmptyColumnType, - - #[error("index name is empty")] - EmptyIndexName, - - #[error("foreign key name is empty")] - EmptyForeignKeyName, - - #[error("operation not supported by SQLite: {0}")] - SqliteNotSupported(&'static str), - - #[error("operation not supported by Postgres: {0}")] - PostgresNotSupported(&'static str), - - #[error("unsupported driver: {0}")] - UnsupportedDriver(String), - - #[error("nothing changed — alter is a no-op")] - NoChange, - - #[error("unsafe column type: {0}")] - UnsafeType(String), - - #[error("unsafe default expression: {0}")] - UnsafeDefault(String), - - #[error("invalid foreign key action: {0}")] - InvalidFkAction(String), - - #[error("index {0} has an expression key or a predicate, which this builder cannot restate")] - UnrenderableIndex(String), -} - -const MAX_TYPE_LEN: usize = 200; -const MAX_DEFAULT_LEN: usize = 500; - -/// Characters that some SQL drivers (notably MySQL with certain -/// client encodings) treat as effective statement terminators or -/// line breaks. ASCII LF/CR are the obvious cases; Unicode -/// `LINE SEPARATOR` (U+2028) and `PARAGRAPH SEPARATOR` (U+2029) round -/// out the set so a crafted type / default string can't smuggle a -/// newline that bypasses the comment / `;` heuristics. -const FORBIDDEN_CONTROL_CHARS: &[char] = &['\0', '\n', '\r', '\u{2028}', '\u{2029}']; - -fn contains_forbidden_control(s: &str) -> bool { - s.chars().any(|c| FORBIDDEN_CONTROL_CHARS.contains(&c)) -} - -/// Reject sequences that escape the type-name syntactic context into -/// statement scope (`;`, comments) or break identifier quoting (double -/// quote, backtick, NUL, line-terminators). Type names may include -/// spaces (`DOUBLE PRECISION`), parens (`VARCHAR(255)`), commas -/// (`DECIMAL(10,2)`), brackets (`INT[]`), single quotes for -/// `ENUM('a','b')`, and dots for schema-qualified user types. -fn validate_safe_type(s: &str) -> Result<(), BuildDdlError> { - if s.len() > MAX_TYPE_LEN { - return Err(BuildDdlError::UnsafeType(s.into())); - } - if s.contains(';') - || s.contains("--") - || s.contains("/*") - || s.contains("*/") - || s.contains('"') - || s.contains('`') - || contains_forbidden_control(s) - { - return Err(BuildDdlError::UnsafeType(s.into())); - } - Ok(()) -} - -/// DEFAULT expressions sit between `DEFAULT` and the next column-def -/// boundary (comma, paren, end of statement). The user can legitimately -/// type literals (`'foo'`, `42`), function calls (`now()`), and -/// SQL-quoted strings with embedded escapes (`'O''Brien'`). The -/// dangerous shapes are statement-terminators and SQL comments — -/// outright reject those. -fn validate_safe_default(s: &str) -> Result<(), BuildDdlError> { - if s.len() > MAX_DEFAULT_LEN { - return Err(BuildDdlError::UnsafeDefault(s.into())); - } - if s.contains(';') || s.contains("--") || s.contains("/*") || s.contains("*/") || contains_forbidden_control(s) { - return Err(BuildDdlError::UnsafeDefault(s.into())); - } - Ok(()) -} - -const FK_ACTIONS: &[ReferentialAction] = &ReferentialAction::ALL; - -/// T-SQL's `ON DELETE` / `ON UPDATE` grammar has no `RESTRICT`; the -/// engine spells that behaviour `NO ACTION`. Emitting `RESTRICT` is a -/// syntax error, so it is not an option the UI may offer either. -const FK_ACTIONS_MSSQL: &[ReferentialAction] = &[ - ReferentialAction::NoAction, - ReferentialAction::Cascade, - ReferentialAction::SetNull, - ReferentialAction::SetDefault, -]; - -/// Referential actions the engine accepts, in the order the UI should -/// present them. The first entry is the SQL default. -pub fn supported_fk_actions(driver_id: &str) -> &'static [ReferentialAction] { - match driver_id { - "mssql" => FK_ACTIONS_MSSQL, - _ => FK_ACTIONS, - } -} - -fn validate_fk_action(driver_id: &str, action: ReferentialAction) -> Result<&'static str, BuildDdlError> { - if supported_fk_actions(driver_id).contains(&action) { - Ok(action.as_sql()) - } else { - Err(BuildDdlError::InvalidFkAction(action.as_sql().to_owned())) - } -} - -/// User-edited column draft. Carries both the original (loaded from -/// `fetch_table_definition`) and the in-flight edit. `original` is -/// `None` for newly-added columns. -#[derive(Debug, Clone, PartialEq)] -pub struct DraftColumn { - pub original: Option, - pub name: String, - pub data_type: String, - pub nullable: bool, - pub primary_key: bool, - pub auto_increment: bool, - pub default_value: Option, - /// What the schema says the column is for. `None` where the - /// engine has no such thing, and where the user cleared it. - pub comment: Option, -} - -/// A column default as the DDL text the form edits. -/// -/// A literal keeps the value's own text; an expression is already the -/// server's spelling. -fn default_text(default: &crate::column::ColumnDefault) -> Option { - match default { - crate::column::ColumnDefault::None => None, - crate::column::ColumnDefault::Literal(value) => crate::export::value_to_text(value), - crate::column::ColumnDefault::Expression(expression) => Some(expression.as_sql().to_owned()), - } -} - -/// The type a column was declared with, which is what DDL restates. -/// -/// Without a catalogue spelling it is empty, so restating the column -/// fails validation rather than splicing the display name. -fn declared_type(column_type: &ColumnType) -> &str { - column_type - .catalog - .as_ref() - .map_or("", |catalog| catalog.declaration.as_sql()) -} - -impl DraftColumn { - /// Build a `DraftColumn` from a column of the table definition, so - /// the user starts with the live state and edits diff against - /// `original`. - pub fn from_definition(definition: ColumnDefinition) -> Self { - let info = &definition.info; - let data_type = declared_type(&info.column_type).to_owned(); - let nullable = info.nullable; - let primary_key = info.primary_key; - let auto_increment = info.is_auto_increment; - let default_value = default_text(&info.default); - let name = info.name.clone(); - let comment = info.comment.clone(); - Self { - original: Some(definition), - name, - data_type, - nullable, - primary_key, - auto_increment, - default_value, - comment, - } - } - - /// True when any of the user-editable attributes differ from the - /// loaded original. New columns (`original = None`) always count - /// as different. Used by the diff path to decide whether the - /// column needs an `AlterColumn` op. - pub fn differs_from_original(&self) -> bool { - self.renamed() || self.differs_beyond_name() - } - - /// True when the column kept its identity and changed its name. - pub fn renamed(&self) -> bool { - match &self.original { - None => false, - Some(orig) => orig.name() != self.name && !self.name.trim().is_empty(), - } - } - - /// True when something other than the name differs, which is what - /// decides whether there is anything for an `AlterColumn` to do. - pub fn differs_beyond_name(&self) -> bool { - match &self.original { - None => true, - Some(orig) => { - let orig = &orig.info; - declared_type(&orig.column_type) != self.data_type - || orig.nullable != self.nullable - || orig.primary_key != self.primary_key - || orig.is_auto_increment != self.auto_increment - || default_text(&orig.default) != self.default_value - || comment_value(orig.comment.as_deref()) != comment_value(self.comment.as_deref()) - } - } - } -} - -/// Pending DDL operation, produced by the diff between the loaded -/// snapshot of a table's structure and the user's in-flight edits. -/// `materialize_ops` consumes these into ordered SQL statements. -/// -/// Identity-bearing fields (`schema`, `table`, name fields) are -/// captured at op-build time; `materialize_ops` doesn't reach back -/// into the model. -#[derive(Debug, Clone, PartialEq)] -pub enum StructureOp { - /// Whole-table create — emitted by `New` mode where the user is - /// drafting a fresh table. `Edit` mode never produces this op. - CreateTable { - schema: Option, - table: String, - columns: Vec, - indexes: Vec, - fks: Vec, - }, - RenameTable { - schema: Option, - old_name: String, - new_name: String, - }, - AddColumn { - schema: Option, - table: String, - column: DraftColumn, - }, - DropColumn { - schema: Option, - table: String, - column_name: String, - }, - /// A column that kept its identity and changed its name. Separate - /// from `AlterColumn` because it has to run first: every other - /// statement addresses the column by its new name, which does not - /// exist until the rename has run. - RenameColumn { - schema: Option, - table: String, - old_name: String, - new_name: String, - }, - /// Single op for any combination of name / type / nullable / - /// default / pk / auto-increment changes on one column. Driver - /// dialect decides how it's split (MySQL: one MODIFY COLUMN; - /// Postgres / SQLite: per-attribute statements). - AlterColumn { - schema: Option, - table: String, - column: DraftColumn, - }, - AddIndex { - schema: Option, - table: String, - index: IndexInfo, - }, - DropIndex { - schema: Option, - table: String, - index_name: String, - }, - AddForeignKey { - schema: Option, - table: String, - fk: ForeignKeyInfo, - }, - DropForeignKey { - schema: Option, - table: String, - fk_name: String, - }, -} - -/// The engine's DDL spelling, which the renames and default drops are -/// written in. -fn ddl_of<'a>(driver_id: &str, dialect: &'a dyn SqlDialect) -> Result<&'a dyn DdlDialect, BuildDdlError> { - dialect - .ddl() - .ok_or_else(|| BuildDdlError::UnsupportedDriver(driver_id.to_owned())) -} - -/// The table a builder names, trimmed the way `qualified_table` trims -/// it. -fn table_ref(schema: Option<&str>, table: &str) -> TableRef { - let schema = schema.map(str::trim).filter(|schema| !schema.is_empty()); - TableRef::new(schema.map(str::to_owned), table.trim()) -} - -fn qualified_table(driver_id: &str, schema: Option<&str>, table: &str) -> String { - // Trim leading / trailing whitespace before quoting so a user - // who typed `" users "` doesn't end up with a literally - // space-padded identifier in the generated DDL. The validator - // rejects after-trim-empty separately; here we only protect - // against accidental padding surviving into the SQL. - let table = table.trim(); - match schema.map(str::trim) { - Some(s) if !s.is_empty() => format!("{}.{}", quote_ident(driver_id, s), quote_ident(driver_id, table)), - _ => quote_ident(driver_id, table), - } -} - -/// An identifier in the engine's own quotes, keyed by driver id like -/// the rest of this builder until the per-engine DDL dialects take over. -fn quote_ident(driver_id: &str, name: &str) -> String { - match driver_id { - "mysql" => format!("`{}`", name.replace('`', "``")), - "clickhouse" => quote_clickhouse_ident(name), - "mssql" => format!("[{}]", name.replace(']', "]]")), - _ => format!("\"{}\"", name.replace('"', "\"\"")), - } -} - -/// ClickHouse's lexer reads a backslash inside backticks as an escape, -/// so doubling the delimiter alone lets `\`` close the identifier early. -fn quote_clickhouse_ident(name: &str) -> String { - let mut quoted = String::with_capacity(name.len() + 2); - quoted.push('`'); - for character in name.chars() { - if matches!(character, '\\' | '\'' | '`' | '\t' | '\n') { - quoted.push('\\'); - } - quoted.push(character); - } - quoted.push('`'); - quoted -} - -/// Escape a value for inclusion in a SQL string literal. Bracket -/// quoting escapes `]`, not `'`, so any identifier that travels as a -/// literal (`sp_rename`'s arguments, an `OBJECT_ID()` lookup) needs -/// this on top of, or instead of, `quote_ident`. -fn sql_literal(value: &str) -> String { - value.replace('\'', "''") -} - -/// MySQL reads a backslash inside a string literal as an escape unless -/// NO_BACKSLASH_ESCAPES is set, so a value ending in one would swallow -/// the closing quote. Doubling the quote is enough on the others. -fn sql_literal_for(driver_id: &str, value: &str) -> String { - match driver_id { - "mysql" => value.replace('\\', "\\\\").replace('\'', "''"), - _ => sql_literal(value), - } -} - -/// An empty entry is the user saying "no description", not a -/// description that happens to be the empty string. -fn comment_value(comment: Option<&str>) -> Option<&str> { - comment.filter(|text| !text.is_empty()) -} - -/// SQL Server keeps a column description in an extended property named -/// `MS_Description`, which is added, updated and dropped by three -/// different procedures. Which one applies depends on whether the -/// column already carried a description, and the loaded original -/// answers that without a round trip. The schema goes through a -/// variable because a procedure argument cannot be a function call. -fn mssql_set_column_comment( - schema: Option<&str>, - table: &str, - column: &str, - had_comment: bool, - comment: Option<&str>, -) -> String { - let table_literal = sql_literal(table.trim()); - let column_literal = sql_literal(column.trim()); - let schema_source = match schema.map(str::trim).filter(|name| !name.is_empty()) { - Some(name) => format!("N'{}'", sql_literal(name)), - None => "SCHEMA_NAME()".to_owned(), - }; - let levels = format!( - "@level0type = N'SCHEMA', @level0name = @comment_schema, \ -@level1type = N'TABLE', @level1name = N'{table_literal}', \ -@level2type = N'COLUMN', @level2name = N'{column_literal}'" - ); - let head = format!("DECLARE @comment_schema sysname = {schema_source}; "); - match comment { - Some(text) => { - let procedure = match had_comment { - true => "sp_updateextendedproperty", - false => "sp_addextendedproperty", - }; - let value = sql_literal(text); - format!("{head}EXEC {procedure} @name = N'MS_Description', @value = N'{value}', {levels}") - } - None => format!("{head}EXEC sp_dropextendedproperty @name = N'MS_Description', {levels}"), - } -} - -/// The statement that writes `column`'s description where the engine -/// keeps it. `None` when there is nothing to write, when the -/// description rides on the column definition (MySQL), or when the -/// engine has nowhere to put one (SQLite). -fn build_column_comment(driver_id: &str, schema: Option<&str>, table: &str, column: &DraftColumn) -> Option { - let comment = comment_value(column.comment.as_deref()); - let had_comment = column - .original - .as_ref() - .and_then(|original| comment_value(original.info.comment.as_deref())) - .is_some(); - if comment.is_none() && !had_comment { - return None; - } - match driver_id { - "postgres" => { - let target = format!( - "{}.{}", - qualified_table(driver_id, schema, table), - quote_ident(driver_id, &column.name) - ); - Some(match comment { - Some(text) => format!("COMMENT ON COLUMN {target} IS '{}'", sql_literal_for(driver_id, text)), - None => format!("COMMENT ON COLUMN {target} IS NULL"), - }) - } - "mssql" => Some(mssql_set_column_comment( - schema, - table, - &column.name, - had_comment, - comment, - )), - _ => None, - } -} - -/// True when the user moved the description away from what the server -/// had, which is the only reason to emit a comment statement for a -/// column that already exists. -fn comment_changed(column: &DraftColumn) -> bool { - let original = column - .original - .as_ref() - .and_then(|original| comment_value(original.info.comment.as_deref())); - original != comment_value(column.comment.as_deref()) -} - -fn validate_table(table: &str) -> Result<(), BuildDdlError> { - if table.trim().is_empty() { - return Err(BuildDdlError::EmptyTableName); - } - Ok(()) -} - -fn validate_column_name(name: &str) -> Result<(), BuildDdlError> { - if name.trim().is_empty() { - return Err(BuildDdlError::EmptyColumnName); - } - Ok(()) -} - -fn validate_column_type(data_type: &str) -> Result<(), BuildDdlError> { - if data_type.trim().is_empty() { - return Err(BuildDdlError::EmptyColumnType); - } - validate_safe_type(data_type)?; - Ok(()) -} - -fn validated_default(default: Option<&str>) -> Result, BuildDdlError> { - let Some(d) = default.filter(|d| !d.is_empty()) else { - return Ok(None); - }; - validate_safe_default(d)?; - Ok(Some(d)) -} - -/// Render one inline column definition for a CREATE TABLE statement. -/// PK is rendered inline only for single-column PK; composite PKs are -/// emitted as a table-level constraint by the caller. -fn render_column_definition(driver_id: &str, column: &DraftColumn, inline_pk: bool) -> Result { - validate_column_name(&column.name)?; - validate_column_type(&column.data_type)?; - let mut parts = vec![quote_ident(driver_id, &column.name), column.data_type.clone()]; - - // SQLite: INTEGER PRIMARY KEY (with optional AUTOINCREMENT) is - // the canonical rowid alias and is its own paragraph in the - // grammar. Render that pattern when the user asked for inline PK - // on a single integer column. AUTOINCREMENT is opt-in (it adds - // monotonic-id guarantees + sqlite_sequence overhead). - if driver_id == "sqlite" && inline_pk && column.primary_key { - parts.push("PRIMARY KEY".into()); - if column.auto_increment { - parts.push("AUTOINCREMENT".into()); - } - if !column.nullable { - parts.push("NOT NULL".into()); - } - if let Some(default) = validated_default(column.default_value.as_deref())? { - parts.push(format!("DEFAULT {default}")); - } - return Ok(parts.join(" ")); - } - - // Postgres SERIAL / BIGSERIAL when auto_increment is requested on - // an integer column. SERIAL implies NOT NULL + a sequence default, - // so don't emit those redundantly. The user-typed type is - // overridden because `serial` IS the type for that pseudo-pattern. - if driver_id == "postgres" && column.auto_increment { - let lower = column.data_type.to_ascii_lowercase(); - let serial_type = if lower.contains("bigint") || lower.contains("int8") { - "BIGSERIAL" - } else if lower.contains("smallint") || lower.contains("int2") { - "SMALLSERIAL" - } else { - "SERIAL" - }; - parts = vec![quote_ident(driver_id, &column.name), serial_type.into()]; - if inline_pk && column.primary_key { - parts.push("PRIMARY KEY".into()); - } - return Ok(parts.join(" ")); - } - - // MSSQL IDENTITY(1,1) auto-increment. An identity column cannot - // carry a DEFAULT, so this bypasses the generic tail entirely — - // unlike Postgres SERIAL, MSSQL identity still honors the user's - // NOT NULL choice instead of forcing one implicitly. - if driver_id == "mssql" && column.auto_increment { - parts.push("IDENTITY(1,1)".into()); - if !column.nullable { - parts.push("NOT NULL".into()); - } - if inline_pk && column.primary_key { - parts.push("PRIMARY KEY".into()); - } - return Ok(parts.join(" ")); - } - - if !column.nullable { - parts.push("NOT NULL".into()); - } - if let Some(default) = validated_default(column.default_value.as_deref())? { - parts.push(format!("DEFAULT {default}")); - } - - if driver_id == "mysql" && column.auto_increment { - parts.push("AUTO_INCREMENT".into()); - } - - if inline_pk && column.primary_key { - parts.push("PRIMARY KEY".into()); - } - - // MySQL hangs the description off the column definition, so every - // statement that restates the definition has to restate it too or - // the description is dropped. - if driver_id == "mysql" - && let Some(text) = comment_value(column.comment.as_deref()) - { - parts.push(format!("COMMENT '{}'", sql_literal_for(driver_id, text))); - } - - Ok(parts.join(" ")) -} - -/// Build CREATE TABLE plus secondary CREATE INDEX / ADD FOREIGN KEY -/// statements as a single ordered Vec ready for execution. The -/// table itself is created first; indexes and FKs follow because -/// some drivers require the table to exist before constraints can -/// reference it. -pub fn build_create_table( - driver_id: &str, - schema: Option<&str>, - table: &str, - columns: &[DraftColumn], - indexes: &[IndexInfo], - fks: &[ForeignKeyInfo], -) -> Result, BuildDdlError> { - validate_table(table)?; - if columns.is_empty() { - return Err(BuildDdlError::NoColumns); - } - let pk_count = columns.iter().filter(|c| c.primary_key).count(); - let inline_pk = pk_count == 1; - - let mut col_defs: Vec = Vec::with_capacity(columns.len() + 1); - for col in columns { - col_defs.push(render_column_definition(driver_id, col, inline_pk)?); - } - if pk_count > 1 { - let pk_cols: Vec = columns - .iter() - .filter(|c| c.primary_key) - .map(|c| quote_ident(driver_id, &c.name)) - .collect(); - col_defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", "))); - } - - let mut out = Vec::with_capacity(1 + indexes.len() + fks.len()); - - let create_sql = format!( - "CREATE TABLE {} (\n {}\n)", - qualified_table(driver_id, schema, table), - col_defs.join(",\n ") - ); - out.push(create_sql); - - for col in columns { - out.extend(build_column_comment(driver_id, schema, table, col)); - } - - for index in indexes { - if index.primary { - // Primary index lives on the inline PK constraint above — - // emitting it again would error. - continue; - } - out.push(build_create_index(driver_id, schema, table, index)?); - } - - if !fks.is_empty() && driver_id == "sqlite" { - // SQLite enforces FK only when this PRAGMA is enabled per - // connection. Emitting it as the first FK statement makes the - // CREATE TABLE flow self-contained. - out.push("PRAGMA foreign_keys = ON".into()); - } - for fk in fks { - out.push(build_add_foreign_key(driver_id, schema, table, fk)?); - } - - Ok(out) -} - -pub fn build_drop_table( - driver_id: &str, - schema: Option<&str>, - table: &str, - if_exists: bool, - cascade: bool, -) -> Result { - validate_table(table)?; - let mut parts = vec!["DROP TABLE".to_string()]; - if if_exists { - parts.push("IF EXISTS".into()); - } - parts.push(qualified_table(driver_id, schema, table)); - if cascade && driver_id == "postgres" { - parts.push("CASCADE".into()); - } - // MySQL / SQLite ignore CASCADE (their FK enforcement is driver- - // side); we don't emit it to keep the generated SQL portable. - Ok(parts.join(" ")) -} - -pub fn build_rename_table( - driver_id: &str, - dialect: &dyn SqlDialect, - schema: Option<&str>, - old_name: &str, - new_name: &str, -) -> Result { - validate_table(old_name)?; - validate_table(new_name)?; - Ok(ddl_of(driver_id, dialect)?.rename_table(dialect, &table_ref(schema, old_name), new_name.trim())) -} - -/// Add one column. Returns more than one statement where the engine -/// keeps a column description outside the column definition, which is -/// everywhere except MySQL. -pub fn build_add_column( - driver_id: &str, - schema: Option<&str>, - table: &str, - column: &DraftColumn, -) -> Result, BuildDdlError> { - validate_table(table)?; - let column_def = render_column_definition(driver_id, column, false)?; - if driver_id == "sqlite" && !column.nullable && column.default_value.as_deref().unwrap_or("").is_empty() { - // SQLite refuses ADD COLUMN NOT NULL unless the column has a - // DEFAULT (or is a generated column we don't yet handle). - // Surface the limit at build time so the UI can show a clear - // error before sending the statement to the driver. - return Err(BuildDdlError::SqliteNotSupported("ADD COLUMN NOT NULL without DEFAULT")); - } - let keyword = if driver_id == "mssql" { "ADD" } else { "ADD COLUMN" }; - let mut out = vec![format!( - "ALTER TABLE {} {} {}", - qualified_table(driver_id, schema, table), - keyword, - column_def - )]; - out.extend(build_column_comment(driver_id, schema, table, column)); - Ok(out) -} - -pub fn build_drop_column( - driver_id: &str, - schema: Option<&str>, - table: &str, - column_name: &str, -) -> Result { - validate_table(table)?; - validate_column_name(column_name)?; - if driver_id == "sqlite" { - // SQLite added DROP COLUMN in 3.35; we trust the runtime - // SQLite to enforce. The UI disables the affordance for - // older runtimes via the same path, but this builder doesn't - // version-detect — the error surfaces from the driver if the - // version is too old. - } - Ok(format!( - "ALTER TABLE {} DROP COLUMN {}", - qualified_table(driver_id, schema, table), - quote_ident(driver_id, column_name) - )) -} - -pub fn build_rename_column( - driver_id: &str, - dialect: &dyn SqlDialect, - schema: Option<&str>, - table: &str, - old_name: &str, - new_name: &str, -) -> Result { - validate_table(table)?; - validate_column_name(old_name)?; - validate_column_name(new_name)?; - Ok(ddl_of(driver_id, dialect)?.rename_column(dialect, &table_ref(schema, table), old_name, new_name)) -} - -/// Apply column type / nullable / default changes. Returns one or -/// more SQL statements: -/// -/// - **MySQL**: a single `ALTER TABLE ... MODIFY COLUMN col_def` that -/// replaces the whole definition. -/// - **Postgres**: one `ALTER TABLE ... ALTER COLUMN ...` per -/// attribute that diffed against `column.original`. Returning a Vec -/// means a single `AlterColumn` op carrying simultaneous type + -/// nullable + default changes maps to up to three statements; the -/// previous single-`String` return cascaded through early-return -/// guards and silently dropped all but the first changed attribute. -/// - **SQLite**: not supported; returns `SqliteNotSupported`. -pub fn build_alter_column( - driver_id: &str, - dialect: &dyn SqlDialect, - schema: Option<&str>, - table: &str, - column: &DraftColumn, -) -> Result, BuildDdlError> { - validate_table(table)?; - validate_column_name(&column.name)?; - let qualified = qualified_table(driver_id, schema, table); - let original = column.original.as_ref(); - let type_changed = original - .map(|o| declared_type(&o.info.column_type) != column.data_type) - .unwrap_or(true); - let nullable_changed = original.map(|o| o.info.nullable != column.nullable).unwrap_or(false); - let default_changed = original - .map(|o| default_text(&o.info.default).as_deref() != column.default_value.as_deref()) - .unwrap_or(column.default_value.is_some()); - // Only an existing column has a default to drop. - let drop_default = || -> Result, BuildDdlError> { - let ddl = ddl_of(driver_id, dialect)?; - Ok(original.and_then(|o| ddl.drop_column_default(dialect, &table_ref(schema, table), &column.name, o))) - }; - match driver_id { - "mysql" => { - // MySQL's MODIFY COLUMN replaces the whole column - // definition. Render the column inline (without inline-PK - // since MODIFY can't change PK) and emit. - let column_def = render_column_definition(driver_id, column, false)?; - Ok(vec![format!("ALTER TABLE {} MODIFY COLUMN {}", qualified, column_def)]) - } - "postgres" => { - // Postgres needs separate sub-statements per attribute. - // Build all that changed and join with `;` so the single - // returned string carries every change. The caller passes - // the result to `Connection::execute` which splits on `;` - // and runs each as a separate statement, matching how - // MySQL's MODIFY COLUMN coalesces several changes into - // one wire-level command. Previously this builder - // returned only the first changed attribute (type wins - // over nullable wins over default), silently losing the - // user's other edits when more than one attribute moved. - let mut stmts: Vec = Vec::new(); - if type_changed { - validate_safe_type(&column.data_type)?; - stmts.push(format!( - "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}::{}", - qualified, - quote_ident(driver_id, &column.name), - column.data_type, - quote_ident(driver_id, &column.name), - column.data_type, - )); - } - if nullable_changed { - stmts.push(if column.nullable { - format!( - "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL", - qualified, - quote_ident(driver_id, &column.name) - ) - } else { - format!( - "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", - qualified, - quote_ident(driver_id, &column.name) - ) - }); - } - if default_changed { - match validated_default(column.default_value.as_deref())? { - Some(default) => stmts.push(format!( - "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}", - qualified, - quote_ident(driver_id, &column.name), - default - )), - None => stmts.extend(drop_default()?), - } - } - if comment_changed(column) { - stmts.extend(build_column_comment(driver_id, schema, table, column)); - } - if stmts.is_empty() { - // Nothing actually changed — surface as NoChange so - // the caller can skip emission. - return Err(BuildDdlError::NoChange); - } - Ok(stmts) - } - "mssql" => { - let mut stmts: Vec = Vec::new(); - // A default is a separate named constraint here, not a - // column attribute, so changing one is drop-then-add. The - // drop goes first: a column bound to a default cannot change - // type. - if default_changed { - stmts.extend(drop_default()?); - } - // ALTER COLUMN carries type and nullability together: T-SQL - // reads an omitted NULL / NOT NULL as NULL, so a type-only - // change has to restate the nullability or it would silently - // drop NOT NULL. - if type_changed || nullable_changed { - validate_safe_type(&column.data_type)?; - let nullability = if column.nullable { "NULL" } else { "NOT NULL" }; - stmts.push(format!( - "ALTER TABLE {} ALTER COLUMN {} {} {}", - qualified, - quote_ident(driver_id, &column.name), - column.data_type, - nullability - )); - } - if default_changed && let Some(default) = validated_default(column.default_value.as_deref())? { - stmts.push(format!( - "ALTER TABLE {} ADD DEFAULT ({}) FOR {}", - qualified, - default, - quote_ident(driver_id, &column.name) - )); - } - if comment_changed(column) { - stmts.extend(build_column_comment(driver_id, schema, table, column)); - } - if stmts.is_empty() { - return Err(BuildDdlError::NoChange); - } - Ok(stmts) - } - "sqlite" => Err(BuildDdlError::SqliteNotSupported( - "ALTER COLUMN (type / nullable / default change)", - )), - other => Err(BuildDdlError::UnsupportedDriver(other.to_string())), - } -} - -/// MySQL-only column reorder. Emits `MODIFY COLUMN ... AFTER other` -/// or `MODIFY COLUMN ... FIRST` when `after` is `None`. -pub fn build_reorder_column( - driver_id: &str, - schema: Option<&str>, - table: &str, - column: &DraftColumn, - after: Option<&str>, -) -> Result { - validate_table(table)?; - validate_column_name(&column.name)?; - if driver_id != "mysql" { - return Err(BuildDdlError::UnsupportedDriver(driver_id.to_string())); - } - let column_def = render_column_definition(driver_id, column, false)?; - let position = match after { - Some(name) if !name.is_empty() => format!("AFTER {}", quote_ident(driver_id, name)), - _ => "FIRST".to_string(), - }; - Ok(format!( - "ALTER TABLE {} MODIFY COLUMN {} {}", - qualified_table(driver_id, schema, table), - column_def, - position, - )) -} - -pub fn build_create_index( - driver_id: &str, - schema: Option<&str>, - table: &str, - index: &IndexInfo, -) -> Result { - validate_table(table)?; - if index.name.trim().is_empty() { - return Err(BuildDdlError::EmptyIndexName); - } - if index.keys.is_empty() { - return Err(BuildDdlError::NoColumns); - } - // An expression key or a predicate is catalog text this builder - // would have to paste in unparsed, so it refuses rather than guess. - if index.predicate.is_some() { - return Err(BuildDdlError::UnrenderableIndex(index.name.clone())); - } - let unique = if index.unique { "UNIQUE " } else { "" }; - let cols = index - .keys - .iter() - .map(|key| match key { - IndexKey::Column { name, descending } => { - let direction = if *descending { " DESC" } else { "" }; - Ok(format!("{}{direction}", quote_ident(driver_id, name))) - } - IndexKey::Expression { .. } => Err(BuildDdlError::UnrenderableIndex(index.name.clone())), - }) - .collect::, BuildDdlError>>()?; - let qualified = qualified_table(driver_id, schema, table); - // MySQL does not accept schema prefix on the index name, and - // CREATE INDEX scopes to the table by default. Postgres / SQLite - // accept schema-qualified index names but the table reference - // already pins the schema. - Ok(format!( - "CREATE {unique}INDEX {} ON {} ({})", - quote_ident(driver_id, &index.name), - qualified, - cols.join(", "), - )) -} - -pub fn build_drop_index( - driver_id: &str, - schema: Option<&str>, - table: &str, - index_name: &str, -) -> Result { - if index_name.trim().is_empty() { - return Err(BuildDdlError::EmptyIndexName); - } - if driver_id == "mysql" { - validate_table(table)?; - // MySQL DROP INDEX needs the table reference; ALTER TABLE - // form is portable across MySQL versions. - return Ok(format!( - "ALTER TABLE {} DROP INDEX {}", - qualified_table(driver_id, schema, table), - quote_ident(driver_id, index_name) - )); - } - if driver_id == "mssql" { - validate_table(table)?; - // MSSQL indexes are not standalone schema objects: DROP INDEX - // must always state the owning table via ON . - return Ok(format!( - "DROP INDEX IF EXISTS {} ON {}", - quote_ident(driver_id, index_name), - qualified_table(driver_id, schema, table) - )); - } - // Postgres + SQLite: schema-qualified index name, no table ref. - let qualified_index = match schema { - Some(s) if !s.is_empty() => format!("{}.{}", quote_ident(driver_id, s), quote_ident(driver_id, index_name)), - _ => quote_ident(driver_id, index_name), - }; - Ok(format!("DROP INDEX IF EXISTS {qualified_index}")) -} - -pub fn build_add_foreign_key( - driver_id: &str, - schema: Option<&str>, - table: &str, - fk: &ForeignKeyInfo, -) -> Result { - validate_table(table)?; - if fk.name.trim().is_empty() { - return Err(BuildDdlError::EmptyForeignKeyName); - } - if fk.columns.is_empty() || fk.ref_columns.is_empty() { - return Err(BuildDdlError::NoColumns); - } - let cols: Vec = fk.columns.iter().map(|c| quote_ident(driver_id, c)).collect(); - let ref_cols: Vec = fk.ref_columns.iter().map(|c| quote_ident(driver_id, c)).collect(); - let ref_table = qualified_table(driver_id, fk.ref_schema.as_deref(), &fk.ref_table); - let mut clauses = vec![format!( - "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})", - qualified_table(driver_id, schema, table), - quote_ident(driver_id, &fk.name), - cols.join(", "), - ref_table, - ref_cols.join(", "), - )]; - if let Some(action) = fk.on_delete { - let action = validate_fk_action(driver_id, action)?; - clauses.push(format!("ON DELETE {action}")); - } - if let Some(action) = fk.on_update { - let action = validate_fk_action(driver_id, action)?; - clauses.push(format!("ON UPDATE {action}")); - } - Ok(clauses.join(" ")) -} - -pub fn build_drop_foreign_key( - driver_id: &str, - schema: Option<&str>, - table: &str, - fk_name: &str, -) -> Result { - validate_table(table)?; - if fk_name.trim().is_empty() { - return Err(BuildDdlError::EmptyForeignKeyName); - } - let qualified = qualified_table(driver_id, schema, table); - match driver_id { - "mysql" => Ok(format!( - "ALTER TABLE {} DROP FOREIGN KEY {}", - qualified, - quote_ident(driver_id, fk_name) - )), - "postgres" | "mssql" => Ok(format!( - "ALTER TABLE {} DROP CONSTRAINT {}", - qualified, - quote_ident(driver_id, fk_name) - )), - "sqlite" => Err(BuildDdlError::SqliteNotSupported( - "DROP FOREIGN KEY (requires table rebuild)", - )), - other => Err(BuildDdlError::UnsupportedDriver(other.to_string())), - } -} - -/// Diff a loaded snapshot against the user's current edits and emit -/// the `StructureOp` list that materializes them. Pure function — no -/// state, no side effects. Replaces the per-keystroke `tracker.push` -/// model with a snapshot-based diff: the model is the source of -/// truth, ops are derived at materialize time. -/// -/// Identity rules: -/// - Columns matched by `DraftColumn.original`'s name. Newly-added -/// columns (`original = None`) emit `AddColumn`. Originals with no -/// matching draft emit `DropColumn`. Drafts whose attributes -/// differ from `original` emit `AlterColumn`. -/// - Indexes / FKs matched by name. Pure rename without other -/// changes ⇒ `Drop` + `Add` (no native ALTER INDEX in the -/// supported drivers). -#[expect( - clippy::too_many_arguments, - reason = "the diff compares the original and draft table name, columns, indexes and foreign keys" -)] -pub fn diff_to_ops( - schema: Option<&str>, - original_table: &str, - current_table: &str, - original_columns: &[ColumnDefinition], - current_columns: &[DraftColumn], - original_indexes: &[IndexInfo], - current_indexes: &[IndexInfo], - original_fks: &[ForeignKeyInfo], - current_fks: &[ForeignKeyInfo], -) -> Vec { - let mut ops = Vec::new(); - let schema_owned = schema.map(|s| s.to_string()); - - // RenameTable - if original_table != current_table && !current_table.trim().is_empty() { - ops.push(StructureOp::RenameTable { - schema: schema_owned.clone(), - old_name: original_table.to_string(), - new_name: current_table.to_string(), - }); - } - - // Use the post-rename table name for child-op identity since - // PostgreSQL applies subsequent ALTERs against the new name. - // MySQL accepts either; SQLite only allows table rename in - // isolation but the materialize ordering puts rename first. - let table = current_table.to_string(); - - // Drop FKs not in current - for fk in original_fks { - if !current_fks.iter().any(|f| f.name == fk.name) { - ops.push(StructureOp::DropForeignKey { - schema: schema_owned.clone(), - table: table.clone(), - fk_name: fk.name.clone(), - }); - } - } - - // Drop indexes not in current. Skip primary indexes — they're - // owned by the column's PK constraint; touching them via DROP - // INDEX would conflict with the column's own state diff. - for idx in original_indexes { - if idx.primary { - continue; - } - if !current_indexes.iter().any(|i| i.name == idx.name) { - ops.push(StructureOp::DropIndex { - schema: schema_owned.clone(), - table: table.clone(), - index_name: idx.name.clone(), - }); - } - } - - // Drop columns: original entries with no matching draft (matched - // by original.name). - for orig in original_columns { - let still_present = current_columns - .iter() - .any(|c| c.original.as_ref().is_some_and(|o| o.name() == orig.name())); - if !still_present { - ops.push(StructureOp::DropColumn { - schema: schema_owned.clone(), - table: table.clone(), - column_name: orig.name().to_owned(), - }); - } - } - - // Rename columns: the draft kept its original and changed its - // name. This goes before the alter because every alter addresses - // the column by its new name. - for col in current_columns { - if let Some(orig) = col.original.as_ref().filter(|_| col.renamed()) { - ops.push(StructureOp::RenameColumn { - schema: schema_owned.clone(), - table: table.clone(), - old_name: orig.name().to_owned(), - new_name: col.name.clone(), - }); - } - } - - // Alter columns: drafts whose original is Some and something - // other than the name differs. A column that only changed name - // has nothing left for an alter to do. - for col in current_columns { - if col.original.is_some() && col.differs_beyond_name() { - ops.push(StructureOp::AlterColumn { - schema: schema_owned.clone(), - table: table.clone(), - column: col.clone(), - }); - } - } - - // Add columns: drafts with no original. - for col in current_columns { - if col.original.is_none() { - ops.push(StructureOp::AddColumn { - schema: schema_owned.clone(), - table: table.clone(), - column: col.clone(), - }); - } - } - - // Add indexes not in original. - for idx in current_indexes { - if idx.primary { - continue; - } - if !original_indexes.iter().any(|i| i.name == idx.name) { - ops.push(StructureOp::AddIndex { - schema: schema_owned.clone(), - table: table.clone(), - index: idx.clone(), - }); - } - } - - // Add FKs not in original. - for fk in current_fks { - if !original_fks.iter().any(|f| f.name == fk.name) { - ops.push(StructureOp::AddForeignKey { - schema: schema_owned.clone(), - table: table.clone(), - fk: fk.clone(), - }); - } - } - - ops -} - -/// Walk a `StructureOp` list and emit the SQL statements in the -/// canonical phased order (rename table → drop FK → drop index → -/// drop column → rename column → alter column → add column → -/// add index → add FK). -/// Splitting between diff (intent) and materialize (SQL emission) -/// keeps the diff side pure and the SQL side driver-aware. -/// -/// `New`-mode `CreateTable` short-circuits the phased pipeline. -pub fn materialize_ops( - ops: &[StructureOp], - driver_id: &str, - dialect: &dyn SqlDialect, -) -> Result, BuildDdlError> { - if let Some(StructureOp::CreateTable { - schema, - table, - columns, - indexes, - fks, - }) = ops.first() - && ops.len() == 1 - { - return build_create_table(driver_id, schema.as_deref(), table, columns, indexes, fks); - } - - let mut out: Vec = Vec::new(); - - for op in ops { - if let StructureOp::RenameTable { - schema, - old_name, - new_name, - } = op - { - out.push(build_rename_table( - driver_id, - dialect, - schema.as_deref(), - old_name, - new_name, - )?); - } - } - for op in ops { - if let StructureOp::DropForeignKey { schema, table, fk_name } = op { - out.push(build_drop_foreign_key(driver_id, schema.as_deref(), table, fk_name)?); - } - } - for op in ops { - if let StructureOp::DropIndex { - schema, - table, - index_name, - } = op - { - out.push(build_drop_index(driver_id, schema.as_deref(), table, index_name)?); - } - } - for op in ops { - if let StructureOp::DropColumn { - schema, - table, - column_name, - } = op - { - out.push(build_drop_column(driver_id, schema.as_deref(), table, column_name)?); - } - } - for op in ops { - if let StructureOp::RenameColumn { - schema, - table, - old_name, - new_name, - } = op - { - out.push(build_rename_column( - driver_id, - dialect, - schema.as_deref(), - table, - old_name, - new_name, - )?); - } - } - for op in ops { - if let StructureOp::AlterColumn { schema, table, column } = op { - match build_alter_column(driver_id, dialect, schema.as_deref(), table, column) { - Ok(stmts) => out.extend(stmts), - Err(BuildDdlError::NoChange) => {} - Err(e) => return Err(e), - } - } - } - for op in ops { - if let StructureOp::AddColumn { schema, table, column } = op { - out.extend(build_add_column(driver_id, schema.as_deref(), table, column)?); - } - } - for op in ops { - if let StructureOp::AddIndex { schema, table, index } = op { - out.push(build_create_index(driver_id, schema.as_deref(), table, index)?); - } - } - for op in ops { - if let StructureOp::AddForeignKey { schema, table, fk } = op { - out.push(build_add_foreign_key(driver_id, schema.as_deref(), table, fk)?); - } - } - - Ok(out) -} - -#[cfg(test)] -mod tests { - use crate::column::{CatalogType, ColumnInfo, ColumnKind, ReadForm, SqlTypeExpr, TextLength}; - use crate::ddl::ColumnAttributes; - use crate::dialect::TestDialect; - - /// The ANSI dialect: renames and default drops take the standard - /// spelling. Each engine's own spelling is tested in its crate. - fn ansi() -> TestDialect { - TestDialect::exact() - } - - fn defined(info: ColumnInfo) -> ColumnDefinition { - ColumnDefinition::new(info, ColumnAttributes::default()) - } - - /// A column type carrying only the server's spelling, which is all - /// the DDL builder reads. - fn test_column_type(name: &str) -> ColumnType { - ColumnType { - name: name.to_owned(), - kind: ColumnKind::Text { - length: TextLength::Unbounded, - }, - catalog: Some(CatalogType { - declaration: SqlTypeExpr::from_catalog_text(name), - cast_target: SqlTypeExpr::from_catalog_text(name), - }), - dynamic_storage: true, - read_form: ReadForm::Native, - } - } - - use super::*; - - fn dc(name: &str, ty: &str) -> DraftColumn { - DraftColumn { - original: None, - name: name.into(), - data_type: ty.into(), - nullable: true, - primary_key: false, - auto_increment: false, - default_value: None, - comment: None, - } - } - - fn pk(mut col: DraftColumn) -> DraftColumn { - col.primary_key = true; - col.nullable = false; - col - } - - fn ai(mut col: DraftColumn) -> DraftColumn { - col.auto_increment = true; - col - } - - fn nn(mut col: DraftColumn) -> DraftColumn { - col.nullable = false; - col - } - - fn def(mut col: DraftColumn, default: &str) -> DraftColumn { - col.default_value = Some(default.into()); - col - } - - fn commented(mut col: DraftColumn, comment: &str) -> DraftColumn { - col.comment = Some(comment.into()); - col - } - - /// The same draft as `edited`, with the server already carrying a - /// description for the column. - fn edited_with_comment(original_name: &str, ty: &str, comment: &str) -> DraftColumn { - let mut col = edited(original_name, ty); - if let Some(original) = col.original.as_mut() { - original.info.comment = Some(comment.into()); - } - col.comment = Some(comment.into()); - col - } - - /// A draft carrying an original, for diffing an edit against the - /// loaded column. - fn edited(original_name: &str, ty: &str) -> DraftColumn { - DraftColumn { - original: Some(defined(ColumnInfo { - name: original_name.into(), - column_type: test_column_type(ty), - nullable: true, - primary_key: false, - is_auto_increment: false, - default: crate::column::ColumnDefault::None, - is_generated: false, - comment: None, - })), - name: original_name.into(), - data_type: ty.into(), - nullable: true, - primary_key: false, - auto_increment: false, - default_value: None, - comment: None, - } - } - - #[test] - fn each_engine_quotes_identifiers_the_way_it_reads() { - assert_eq!(quote_ident("postgres", "a\"b"), "\"a\"\"b\""); - assert_eq!(quote_ident("mysql", "a`b"), "`a``b`"); - assert_eq!(quote_ident("mssql", "a]b"), "[a]]b]"); - assert_eq!(quote_ident("clickhouse", "a\\`b"), "`a\\\\\\`b`"); - } - - #[test] - fn renaming_a_column_emits_a_rename() { - let mut draft = edited("old_name", "text"); - draft.name = "new_name".into(); - - let ops = diff_to_ops(None, "t", "t", &[], &[draft], &[], &[], &[], &[]); - - assert_eq!( - ops, - vec![StructureOp::RenameColumn { - schema: None, - table: "t".into(), - old_name: "old_name".into(), - new_name: "new_name".into(), - }], - "a rename produced no rename op" - ); - } - - #[test] - fn a_rename_reaches_the_server_on_every_engine_that_has_one() { - let mut draft = edited("old_name", "text"); - draft.name = "new_name".into(); - let ops = diff_to_ops(None, "t", "t", &[], &[draft], &[], &[], &[], &[]); - - for driver in ["postgres", "mysql", "sqlite", "mssql"] { - let sql = materialize_ops(&ops, driver, &ansi()).expect("the rename builds"); - assert_eq!(sql.len(), 1, "{driver} emitted {sql:?}"); - assert!( - sql[0].contains("RENAME COLUMN") && sql[0].contains("new_name"), - "{driver} emitted {:?}", - sql[0] - ); - } - } - - #[test] - fn a_rename_lands_before_the_alter_that_addresses_the_new_name() { - // Renaming and retyping at once: the ALTER names the column - // its new name, so it can only run once the rename has. - let mut draft = edited("old_name", "text"); - draft.name = "new_name".into(); - draft.data_type = "varchar(20)".into(); - - let ops = diff_to_ops(None, "t", "t", &[], &[draft], &[], &[], &[], &[]); - let sql = materialize_ops(&ops, "postgres", &ansi()).expect("the change builds"); - - let rename = sql.iter().position(|s| s.contains("RENAME COLUMN")); - let alter = sql.iter().position(|s| s.contains("TYPE varchar(20)")); - assert!(rename.is_some() && alter.is_some(), "got {sql:?}"); - assert!(rename < alter, "the alter ran before the rename: {sql:?}"); - } - - #[test] - fn a_column_whose_name_alone_changed_is_not_also_altered() { - let mut draft = edited("old_name", "text"); - draft.name = "new_name".into(); - - let ops = diff_to_ops(None, "t", "t", &[], &[draft], &[], &[], &[], &[]); - - assert!( - !ops.iter().any(|op| matches!(op, StructureOp::AlterColumn { .. })), - "a pure rename asked for an alter with nothing to alter: {ops:?}" - ); - } - - #[test] - fn create_table_simple_postgres() { - let cols = vec![pk(ai(dc("id", "integer"))), nn(dc("email", "text"))]; - let stmts = build_create_table("postgres", None, "users", &cols, &[], &[]).unwrap(); - assert_eq!(stmts.len(), 1); - assert!(stmts[0].contains("\"id\" SERIAL PRIMARY KEY")); - assert!(stmts[0].contains("\"email\" text NOT NULL")); - assert!(stmts[0].starts_with("CREATE TABLE \"users\"")); - } - - #[test] - fn create_table_simple_mysql() { - let cols = vec![pk(ai(dc("id", "INT"))), nn(dc("email", "VARCHAR(255)"))]; - let stmts = build_create_table("mysql", None, "users", &cols, &[], &[]).unwrap(); - assert_eq!(stmts.len(), 1); - assert!(stmts[0].contains("`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY")); - assert!(stmts[0].contains("`email` VARCHAR(255) NOT NULL")); - } - - #[test] - fn create_table_simple_sqlite() { - let cols = vec![pk(ai(dc("id", "INTEGER"))), nn(dc("email", "TEXT"))]; - let stmts = build_create_table("sqlite", None, "users", &cols, &[], &[]).unwrap(); - assert_eq!(stmts.len(), 1); - assert!(stmts[0].contains("\"id\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL")); - assert!(stmts[0].contains("\"email\" TEXT NOT NULL")); - } - - #[test] - fn create_table_simple_mssql() { - let mut cols = vec![ai(dc("id", "INT")), nn(dc("email", "VARCHAR(255)"))]; - cols[0].primary_key = true; - let stmts = build_create_table("mssql", None, "users", &cols, &[], &[]).unwrap(); - assert_eq!(stmts.len(), 1); - assert!(stmts[0].contains("[id] INT IDENTITY(1,1) PRIMARY KEY")); - assert!(!stmts[0].contains("DEFAULT")); - assert!(stmts[0].contains("[email] VARCHAR(255) NOT NULL")); - assert!(stmts[0].starts_with("CREATE TABLE [users]")); - } - - #[test] - fn create_table_postgres_bigserial() { - let cols = vec![pk(ai(dc("id", "bigint")))]; - let stmts = build_create_table("postgres", None, "t", &cols, &[], &[]).unwrap(); - assert!(stmts[0].contains("BIGSERIAL")); - } - - #[test] - fn create_table_composite_pk() { - let cols = vec![nn(pk(dc("a", "int"))), nn(pk(dc("b", "int"))), dc("c", "text")]; - let stmts = build_create_table("postgres", None, "t", &cols, &[], &[]).unwrap(); - // Inline PK only fires for single-column PK — composite emits - // a separate PRIMARY KEY (a, b) clause at the end. - assert!(!stmts[0].contains("PRIMARY KEY,")); - assert!(stmts[0].contains("PRIMARY KEY (\"a\", \"b\")")); - } - - #[test] - fn create_table_with_default() { - let cols = vec![nn(pk(ai(dc("id", "integer")))), def(dc("status", "text"), "'pending'")]; - let stmts = build_create_table("postgres", None, "t", &cols, &[], &[]).unwrap(); - assert!(stmts[0].contains("DEFAULT 'pending'")); - } - - #[test] - fn create_table_with_schema() { - let cols = vec![nn(pk(dc("id", "integer")))]; - let stmts = build_create_table("postgres", Some("auth"), "users", &cols, &[], &[]).unwrap(); - assert!(stmts[0].starts_with("CREATE TABLE \"auth\".\"users\"")); - } - - #[test] - fn create_table_with_secondary_index() { - let cols = vec![nn(pk(ai(dc("id", "integer")))), nn(dc("email", "text"))]; - let idx = index("users_email_idx", &["email"], true, false); - let stmts = build_create_table("postgres", None, "users", &cols, &[idx], &[]).unwrap(); - assert_eq!(stmts.len(), 2); - assert!(stmts[1].contains("CREATE UNIQUE INDEX")); - assert!(stmts[1].contains("\"users_email_idx\"")); - } - - #[test] - fn create_table_skips_primary_index() { - let cols = vec![nn(pk(ai(dc("id", "integer"))))]; - let pk_idx = index("users_pkey", &["id"], true, true); - let stmts = build_create_table("postgres", None, "users", &cols, &[pk_idx], &[]).unwrap(); - assert_eq!(stmts.len(), 1, "primary index must not produce a separate CREATE INDEX"); - } - - #[test] - fn create_table_with_foreign_key() { - let cols = vec![nn(pk(ai(dc("id", "integer")))), nn(dc("user_id", "integer"))]; - let fk = ForeignKeyInfo { - name: "fk_user".into(), - columns: vec!["user_id".into()], - ref_schema: None, - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: Some(ReferentialAction::Cascade), - on_update: None, - }; - let stmts = build_create_table("postgres", None, "orders", &cols, &[], &[fk]).unwrap(); - assert_eq!(stmts.len(), 2); - assert!(stmts[1].contains("ADD CONSTRAINT \"fk_user\"")); - assert!(stmts[1].contains("ON DELETE CASCADE")); - } - - #[test] - fn create_table_sqlite_emits_pragma_for_fk() { - let cols = vec![nn(pk(dc("id", "INTEGER"))), nn(dc("user_id", "INTEGER"))]; - let fk = ForeignKeyInfo { - name: "fk_user".into(), - columns: vec!["user_id".into()], - ref_schema: None, - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: None, - on_update: None, - }; - let stmts = build_create_table("sqlite", None, "orders", &cols, &[], &[fk]).unwrap(); - assert_eq!(stmts.len(), 3); - assert_eq!(stmts[1], "PRAGMA foreign_keys = ON"); - } - - #[test] - fn create_table_rejects_empty_name() { - let cols = vec![dc("a", "int")]; - let err = build_create_table("postgres", None, "", &cols, &[], &[]).unwrap_err(); - assert!(matches!(err, BuildDdlError::EmptyTableName)); - } - - #[test] - fn create_table_rejects_no_columns() { - let err = build_create_table("postgres", None, "t", &[], &[], &[]).unwrap_err(); - assert!(matches!(err, BuildDdlError::NoColumns)); - } - - #[test] - fn drop_table_basic() { - assert_eq!( - build_drop_table("postgres", None, "users", false, false).unwrap(), - "DROP TABLE \"users\"" - ); - assert_eq!( - build_drop_table("mysql", None, "users", true, false).unwrap(), - "DROP TABLE IF EXISTS `users`" - ); - assert_eq!( - build_drop_table("postgres", Some("auth"), "users", true, true).unwrap(), - "DROP TABLE IF EXISTS \"auth\".\"users\" CASCADE" - ); - } - - #[test] - fn drop_table_cascade_only_postgres() { - assert!( - !build_drop_table("mysql", None, "t", false, true) - .unwrap() - .contains("CASCADE") - ); - assert!( - !build_drop_table("sqlite", None, "t", false, true) - .unwrap() - .contains("CASCADE") - ); - } - - #[test] - fn drop_table_mssql_no_cascade() { - assert_eq!( - build_drop_table("mssql", None, "users", false, false).unwrap(), - "DROP TABLE [users]" - ); - assert_eq!( - build_drop_table("mssql", Some("dbo"), "users", true, true).unwrap(), - "DROP TABLE IF EXISTS [dbo].[users]" - ); - } - - #[test] - fn rename_table_goes_through_the_engine_ddl() { - assert_eq!( - build_rename_table("postgres", &ansi(), Some(" app "), " old ", "new").unwrap(), - "ALTER TABLE \"app\".\"old\" RENAME TO \"new\"" - ); - } - - #[test] - fn an_engine_without_ddl_renames_nothing() { - let no_ddl = TestDialect::exact().without_ddl(); - - assert!(matches!( - build_rename_table("postgres", &no_ddl, None, "old", "new"), - Err(BuildDdlError::UnsupportedDriver(_)) - )); - assert!(build_rename_table("postgres", &ansi(), None, " ", "new").is_err()); - } - - #[test] - fn add_column_basic() { - let col = nn(def(dc("created_at", "timestamp"), "now()")); - assert_eq!( - build_add_column("postgres", None, "users", &col).unwrap(), - vec!["ALTER TABLE \"users\" ADD COLUMN \"created_at\" timestamp NOT NULL DEFAULT now()".to_owned()] - ); - } - - #[test] - fn add_column_sqlite_not_null_without_default_rejected() { - let col = nn(dc("name", "text")); - let err = build_add_column("sqlite", None, "t", &col).unwrap_err(); - assert!(matches!(err, BuildDdlError::SqliteNotSupported(_))); - } - - #[test] - fn add_column_sqlite_with_default_ok() { - let col = nn(def(dc("name", "TEXT"), "''")); - let sql = build_add_column("sqlite", None, "t", &col).unwrap(); - assert!(sql[0].starts_with("ALTER TABLE \"t\" ADD COLUMN")); - } - - #[test] - fn add_column_mssql_no_column_keyword() { - let col = nn(def(dc("created_at", "DATETIME2"), "SYSUTCDATETIME()")); - let sql = build_add_column("mssql", None, "users", &col).unwrap(); - assert_eq!( - sql, - vec!["ALTER TABLE [users] ADD [created_at] DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()".to_owned()] - ); - assert!(!sql[0].contains("ADD COLUMN")); - } - - #[test] - fn drop_column_each_driver() { - assert_eq!( - build_drop_column("postgres", None, "users", "email").unwrap(), - "ALTER TABLE \"users\" DROP COLUMN \"email\"" - ); - assert_eq!( - build_drop_column("mysql", None, "users", "email").unwrap(), - "ALTER TABLE `users` DROP COLUMN `email`" - ); - assert_eq!( - build_drop_column("sqlite", None, "users", "email").unwrap(), - "ALTER TABLE \"users\" DROP COLUMN \"email\"" - ); - } - - #[test] - fn drop_column_mssql() { - assert_eq!( - build_drop_column("mssql", None, "users", "email").unwrap(), - "ALTER TABLE [users] DROP COLUMN [email]" - ); - } - - #[test] - fn rename_column_goes_through_the_engine_ddl() { - assert_eq!( - build_rename_column("postgres", &ansi(), None, "t", "old", "new").unwrap(), - "ALTER TABLE \"t\" RENAME COLUMN \"old\" TO \"new\"" - ); - } - - #[test] - fn alter_column_postgres_type_change() { - let col = DraftColumn { - original: Some(defined(ColumnInfo { - name: "x".into(), - column_type: test_column_type("integer"), - nullable: true, - primary_key: false, - is_auto_increment: false, - default: crate::column::ColumnDefault::None, - is_generated: false, - comment: None, - })), - name: "x".into(), - data_type: "bigint".into(), - nullable: true, - primary_key: false, - auto_increment: false, - default_value: None, - comment: None, - }; - let stmts = build_alter_column("postgres", &ansi(), None, "t", &col).unwrap(); - let joined = stmts.join("\n"); - assert!(joined.contains("TYPE bigint")); - assert!(joined.contains("USING \"x\"::bigint")); - } - - #[test] - fn alter_column_postgres_nullable_change() { - let col = DraftColumn { - original: Some(defined(ColumnInfo { - name: "x".into(), - column_type: test_column_type("text"), - nullable: true, - primary_key: false, - is_auto_increment: false, - default: crate::column::ColumnDefault::None, - is_generated: false, - comment: None, - })), - name: "x".into(), - data_type: "text".into(), - nullable: false, - primary_key: false, - auto_increment: false, - default_value: None, - comment: None, - }; - let stmts = build_alter_column("postgres", &ansi(), None, "t", &col).unwrap(); - assert!(stmts.iter().any(|s| s.contains("SET NOT NULL"))); - } - - #[test] - fn alter_column_postgres_default_change() { - let col = DraftColumn { - original: Some(defined(ColumnInfo { - name: "x".into(), - column_type: test_column_type("text"), - nullable: true, - primary_key: false, - is_auto_increment: false, - default: crate::column::ColumnDefault::None, - is_generated: false, - comment: None, - })), - name: "x".into(), - data_type: "text".into(), - nullable: true, - primary_key: false, - auto_increment: false, - default_value: Some("'pending'".into()), - comment: None, - }; - let stmts = build_alter_column("postgres", &ansi(), None, "t", &col).unwrap(); - assert!(stmts.iter().any(|s| s.contains("SET DEFAULT 'pending'"))); - } - - #[test] - fn clearing_a_default_drops_it_by_the_column_s_new_name() { - let mut col = edited("old_name", "text"); - if let Some(original) = col.original.as_mut() { - original.info.default = - crate::column::ColumnDefault::Expression(crate::column::SqlExpression::from_catalog_text("now()")); - } - col.name = "new_name".into(); - - let stmts = build_alter_column("postgres", &ansi(), None, "t", &col).unwrap(); - - assert_eq!( - stmts, - vec!["ALTER TABLE \"t\" ALTER COLUMN \"new_name\" DROP DEFAULT".to_owned()] - ); - } - - #[test] - fn alter_column_mysql_modify_full_def() { - let col = nn(def(dc("status", "VARCHAR(64)"), "'open'")); - let stmts = build_alter_column("mysql", &ansi(), None, "t", &col).unwrap(); - assert_eq!(stmts.len(), 1); - assert_eq!( - stmts[0], - "ALTER TABLE `t` MODIFY COLUMN `status` VARCHAR(64) NOT NULL DEFAULT 'open'" - ); - } - - #[test] - fn alter_column_postgres_emits_three_statements_when_all_change() { - let col = DraftColumn { - original: Some(defined(ColumnInfo { - name: "x".into(), - column_type: test_column_type("integer"), - nullable: true, - primary_key: false, - is_auto_increment: false, - default: crate::column::ColumnDefault::None, - is_generated: false, - comment: None, - })), - name: "x".into(), - data_type: "bigint".into(), - nullable: false, - primary_key: false, - auto_increment: false, - default_value: Some("'fallback'".into()), - comment: None, - }; - let stmts = build_alter_column("postgres", &ansi(), None, "t", &col).unwrap(); - // Type, nullable AND default all changed — all three must - // emit. Previously the early-return cascade lost the latter - // two. - assert_eq!(stmts.len(), 3); - assert!(stmts[0].contains("TYPE bigint")); - assert!(stmts[1].contains("SET NOT NULL")); - assert!(stmts[2].contains("SET DEFAULT 'fallback'")); - } - - #[test] - fn alter_column_sqlite_rejected() { - let col = dc("x", "TEXT"); - let err = build_alter_column("sqlite", &ansi(), None, "t", &col).unwrap_err(); - assert!(matches!(err, BuildDdlError::SqliteNotSupported(_))); - } - - #[test] - fn alter_column_mssql_type_and_nullable_change() { - let col = DraftColumn { - original: Some(defined(ColumnInfo { - name: "x".into(), - column_type: test_column_type("int"), - nullable: true, - primary_key: false, - is_auto_increment: false, - default: crate::column::ColumnDefault::None, - is_generated: false, - comment: None, - })), - name: "x".into(), - data_type: "int".into(), - nullable: false, - primary_key: false, - auto_increment: false, - default_value: None, - comment: None, - }; - let stmts = build_alter_column("mssql", &ansi(), None, "t", &col).unwrap(); - assert_eq!(stmts.len(), 1); - assert_eq!(stmts[0], "ALTER TABLE [t] ALTER COLUMN [x] int NOT NULL"); - } - - #[test] - fn alter_column_mssql_unchanged_is_no_change() { - let col = DraftColumn { - original: Some(defined(ColumnInfo { - name: "x".into(), - column_type: test_column_type("int"), - nullable: true, - primary_key: false, - is_auto_increment: false, - default: crate::column::ColumnDefault::None, - is_generated: false, - comment: None, - })), - name: "x".into(), - data_type: "int".into(), - nullable: true, - primary_key: false, - auto_increment: false, - default_value: None, - comment: None, - }; - let err = build_alter_column("mssql", &ansi(), None, "t", &col).unwrap_err(); - assert!(matches!(err, BuildDdlError::NoChange)); - } - - #[test] - fn reorder_column_mysql() { - let col = nn(dc("status", "VARCHAR(64)")); - let sql = build_reorder_column("mysql", None, "t", &col, Some("name")).unwrap(); - assert_eq!( - sql, - "ALTER TABLE `t` MODIFY COLUMN `status` VARCHAR(64) NOT NULL AFTER `name`" - ); - } - - #[test] - fn reorder_column_mysql_first() { - let col = nn(dc("id", "INT")); - let sql = build_reorder_column("mysql", None, "t", &col, None).unwrap(); - assert!(sql.ends_with("FIRST")); - } - - #[test] - fn reorder_column_postgres_rejected() { - let col = dc("x", "text"); - let err = build_reorder_column("postgres", None, "t", &col, Some("y")).unwrap_err(); - assert!(matches!(err, BuildDdlError::UnsupportedDriver(_))); - } - - #[test] - fn reorder_column_sqlite_rejected() { - let col = dc("x", "TEXT"); - let err = build_reorder_column("sqlite", None, "t", &col, None).unwrap_err(); - assert!(matches!(err, BuildDdlError::UnsupportedDriver(_))); - } - - fn index(name: &str, columns: &[&str], unique: bool, primary: bool) -> IndexInfo { - IndexInfo { - name: name.to_owned(), - keys: columns - .iter() - .map(|column| IndexKey::Column { - name: (*column).to_owned(), - descending: false, - }) - .collect(), - unique, - primary, - predicate: None, - } - } - - #[test] - fn create_index_keeps_a_descending_key() { - let mut idx = index("recent_first", &["created_at"], false, false); - idx.keys = vec![IndexKey::Column { - name: "created_at".to_owned(), - descending: true, - }]; - - assert_eq!( - build_create_index("postgres", None, "events", &idx).unwrap(), - "CREATE INDEX \"recent_first\" ON \"events\" (\"created_at\" DESC)" - ); - } - - #[test] - fn create_index_refuses_what_it_would_paste_unparsed() { - let mut functional = index("by_lower_email", &[], true, false); - functional.keys = vec![IndexKey::Expression { - sql: "lower(email)".to_owned(), - descending: false, - }]; - let mut partial = index("live_email", &["email"], true, false); - partial.predicate = Some("deleted_at IS NULL".to_owned()); - - for idx in [functional, partial] { - let err = build_create_index("postgres", None, "users", &idx).unwrap_err(); - assert!( - matches!(&err, BuildDdlError::UnrenderableIndex(name) if *name == idx.name), - "got {err:?}" - ); - } - } - - #[test] - fn create_index_basic() { - let idx = index("users_email_idx", &["email"], true, false); - assert_eq!( - build_create_index("postgres", None, "users", &idx).unwrap(), - "CREATE UNIQUE INDEX \"users_email_idx\" ON \"users\" (\"email\")" - ); - } - - #[test] - fn create_index_compound_columns() { - let idx = index("idx_a_b", &["a", "b"], false, false); - let sql = build_create_index("mysql", None, "t", &idx).unwrap(); - assert_eq!(sql, "CREATE INDEX `idx_a_b` ON `t` (`a`, `b`)"); - } - - #[test] - fn create_index_rejects_empty_name() { - let idx = index("", &["x"], false, false); - let err = build_create_index("postgres", None, "t", &idx).unwrap_err(); - assert!(matches!(err, BuildDdlError::EmptyIndexName)); - } - - #[test] - fn create_index_mssql() { - let idx = index("idx_a_b", &["a", "b"], true, false); - let sql = build_create_index("mssql", None, "t", &idx).unwrap(); - assert_eq!(sql, "CREATE UNIQUE INDEX [idx_a_b] ON [t] ([a], [b])"); - } - - #[test] - fn drop_index_postgres() { - assert_eq!( - build_drop_index("postgres", Some("public"), "t", "my_idx").unwrap(), - "DROP INDEX IF EXISTS \"public\".\"my_idx\"" - ); - } - - #[test] - fn drop_index_mysql_uses_alter_table() { - assert_eq!( - build_drop_index("mysql", None, "t", "my_idx").unwrap(), - "ALTER TABLE `t` DROP INDEX `my_idx`" - ); - } - - #[test] - fn drop_index_sqlite() { - assert_eq!( - build_drop_index("sqlite", None, "t", "my_idx").unwrap(), - "DROP INDEX IF EXISTS \"my_idx\"" - ); - } - - #[test] - fn drop_index_mssql() { - assert_eq!( - build_drop_index("mssql", Some("schema"), "t", "ix").unwrap(), - "DROP INDEX IF EXISTS [ix] ON [schema].[t]" - ); - } - - fn fk_basic() -> ForeignKeyInfo { - ForeignKeyInfo { - name: "fk_user".into(), - columns: vec!["user_id".into()], - ref_schema: None, - ref_table: "users".into(), - ref_columns: vec!["id".into()], - on_delete: Some(ReferentialAction::Cascade), - on_update: Some(ReferentialAction::Restrict), - } - } - - #[test] - fn add_foreign_key_postgres() { - let sql = build_add_foreign_key("postgres", None, "orders", &fk_basic()).unwrap(); - assert!(sql.contains("ADD CONSTRAINT \"fk_user\"")); - assert!(sql.contains("FOREIGN KEY (\"user_id\")")); - assert!(sql.contains("REFERENCES \"users\" (\"id\")")); - assert!(sql.contains("ON DELETE CASCADE")); - assert!(sql.contains("ON UPDATE RESTRICT")); - } - - #[test] - fn add_foreign_key_mysql_backticks() { - let sql = build_add_foreign_key("mysql", None, "orders", &fk_basic()).unwrap(); - assert!(sql.contains("`fk_user`")); - assert!(sql.contains("`user_id`")); - } - - #[test] - fn add_foreign_key_omits_actions_when_none() { - let mut fk = fk_basic(); - fk.on_delete = None; - fk.on_update = None; - let sql = build_add_foreign_key("postgres", None, "orders", &fk).unwrap(); - assert!(!sql.contains("ON DELETE")); - assert!(!sql.contains("ON UPDATE")); - } - - #[test] - fn add_foreign_key_mssql() { - let mut fk = fk_basic(); - fk.on_update = Some(ReferentialAction::NoAction); - let sql = build_add_foreign_key("mssql", None, "orders", &fk).unwrap(); - assert!(sql.contains("ADD CONSTRAINT [fk_user]")); - assert!(sql.contains("FOREIGN KEY ([user_id])")); - assert!(sql.contains("REFERENCES [users] ([id])")); - assert!(sql.contains("ON DELETE CASCADE")); - assert!(sql.contains("ON UPDATE NO ACTION")); - } - - #[test] - fn add_foreign_key_mssql_rejects_restrict() { - // T-SQL has no RESTRICT. Emitting it produces a syntax error at - // Save time, so the builder refuses it up front and the dialog - // never offers it. - let err = build_add_foreign_key("mssql", None, "orders", &fk_basic()).unwrap_err(); - assert!(matches!(err, BuildDdlError::InvalidFkAction(a) if a == "RESTRICT")); - assert!(!supported_fk_actions("mssql").contains(&ReferentialAction::Restrict)); - assert!(supported_fk_actions("postgres").contains(&ReferentialAction::Restrict)); - } - - #[test] - fn drop_foreign_key_postgres() { - assert_eq!( - build_drop_foreign_key("postgres", None, "orders", "fk_user").unwrap(), - "ALTER TABLE \"orders\" DROP CONSTRAINT \"fk_user\"" - ); - } - - #[test] - fn drop_foreign_key_mysql() { - assert_eq!( - build_drop_foreign_key("mysql", None, "orders", "fk_user").unwrap(), - "ALTER TABLE `orders` DROP FOREIGN KEY `fk_user`" - ); - } - - #[test] - fn drop_foreign_key_sqlite_rejected() { - let err = build_drop_foreign_key("sqlite", None, "orders", "fk_user").unwrap_err(); - assert!(matches!(err, BuildDdlError::SqliteNotSupported(_))); - } - - #[test] - fn drop_foreign_key_mssql() { - assert_eq!( - build_drop_foreign_key("mssql", None, "orders", "fk_user").unwrap(), - "ALTER TABLE [orders] DROP CONSTRAINT [fk_user]" - ); - } - - #[test] - fn rejects_injection_via_data_type() { - let mut col = dc("x", "INT; DROP TABLE users; --"); - col.nullable = false; - let err = build_add_column("postgres", None, "t", &col).unwrap_err(); - assert!(matches!(err, BuildDdlError::UnsafeType(_)), "got {err:?}"); - } - - #[test] - fn rejects_injection_via_default() { - let col = def(dc("x", "TEXT"), "'a'); DROP TABLE t; --"); - let err = build_add_column("postgres", None, "t", &col).unwrap_err(); - assert!(matches!(err, BuildDdlError::UnsafeDefault(_)), "got {err:?}"); - } - - #[test] - fn rejects_unicode_line_separator_in_type() { - let mut col = dc("x", "INT\u{2028}; DROP TABLE u; --"); - col.nullable = false; - let err = build_add_column("postgres", None, "t", &col).unwrap_err(); - assert!(matches!(err, BuildDdlError::UnsafeType(_)), "got {err:?}"); - } - - #[test] - fn rejects_unicode_paragraph_separator_in_default() { - let col = def(dc("x", "TEXT"), "'a\u{2029}; DROP TABLE u; --"); - let err = build_add_column("postgres", None, "t", &col).unwrap_err(); - assert!(matches!(err, BuildDdlError::UnsafeDefault(_)), "got {err:?}"); - } - - #[test] - fn allows_legitimate_complex_types() { - // Postgres time-with-tz, ENUM with quoted labels, parameterised - // DECIMAL, array suffix — must all pass. - for ty in [ - "TIMESTAMP WITH TIME ZONE", - "DOUBLE PRECISION", - "DECIMAL(10, 2)", - "INTEGER[]", - "ENUM('open','closed')", - "Nullable(Int64)", - "VARCHAR(255)", - ] { - assert!(validate_safe_type(ty).is_ok(), "rejected legitimate type: {ty}"); - } - } - - #[test] - fn allows_legitimate_default_expressions() { - for d in ["'foo'", "42", "now()", "CURRENT_TIMESTAMP", "'O''Brien'", "(1+2)"] { - assert!(validate_safe_default(d).is_ok(), "rejected legitimate default: {d}"); - } - } - - #[test] - fn alter_column_postgres_rejects_injection_in_type() { - let col = DraftColumn { - original: Some(defined(ColumnInfo { - name: "x".into(), - column_type: test_column_type("integer"), - nullable: true, - primary_key: false, - is_auto_increment: false, - default: crate::column::ColumnDefault::None, - is_generated: false, - comment: None, - })), - name: "x".into(), - data_type: "bigint; DROP TABLE u; --".into(), - nullable: true, - primary_key: false, - auto_increment: false, - default_value: None, - comment: None, - }; - let err = build_alter_column("postgres", &ansi(), None, "t", &col).unwrap_err(); - assert!(matches!(err, BuildDdlError::UnsafeType(_)), "got {err:?}"); - } - - #[test] - fn quoted_identifiers_round_trip_through_qualified_table() { - // Schema + table with embedded quote chars: identifier quoting - // must double the inner quote. - let sql = build_drop_table("postgres", Some("a\"b"), "c\"d", false, false).unwrap(); - assert!(sql.contains("\"a\"\"b\".\"c\"\"d\"")); - } - - #[test] - fn postgres_writes_a_column_comment_as_its_own_statement() { - let mut col = edited("email", "text"); - col.comment = Some("primary contact".into()); - - let stmts = build_alter_column("postgres", &ansi(), Some("public"), "users", &col).unwrap(); - - assert_eq!( - stmts, - vec!["COMMENT ON COLUMN \"public\".\"users\".\"email\" IS 'primary contact'".to_owned()] - ); - } - - #[test] - fn postgres_clears_a_column_comment_with_is_null() { - let mut col = edited_with_comment("email", "text", "primary contact"); - col.comment = None; - - let stmts = build_alter_column("postgres", &ansi(), None, "users", &col).unwrap(); - - assert_eq!(stmts, vec!["COMMENT ON COLUMN \"users\".\"email\" IS NULL".to_owned()]); - } - - #[test] - fn an_empty_comment_reads_as_no_comment() { - let mut col = edited("email", "text"); - col.comment = Some(String::new()); - - assert!(!col.differs_beyond_name()); - assert!(matches!( - build_alter_column("postgres", &ansi(), None, "users", &col), - Err(BuildDdlError::NoChange) - )); - } - - #[test] - fn an_unchanged_comment_emits_nothing() { - let col = edited_with_comment("email", "text", "primary contact"); - - assert!(matches!( - build_alter_column("postgres", &ansi(), None, "users", &col), - Err(BuildDdlError::NoChange) - )); - } - - #[test] - fn mysql_carries_the_comment_on_the_column_definition() { - let col = commented(nn(dc("email", "VARCHAR(255)")), "primary contact"); - - let stmts = build_alter_column("mysql", &ansi(), None, "users", &col).unwrap(); - - assert_eq!( - stmts, - vec![ - "ALTER TABLE `users` MODIFY COLUMN `email` VARCHAR(255) NOT NULL COMMENT 'primary contact'".to_owned() - ] - ); - } - - #[test] - fn mysql_restates_an_untouched_comment_so_modify_does_not_drop_it() { - let mut col = edited_with_comment("email", "text", "primary contact"); - col.nullable = false; - - let stmts = build_alter_column("mysql", &ansi(), None, "users", &col).unwrap(); - - assert!(stmts[0].ends_with("COMMENT 'primary contact'")); - } - - #[test] - fn mysql_escapes_a_backslash_in_a_comment() { - // MySQL reads a backslash as an escape, so a trailing one would - // otherwise swallow the closing quote and splice the rest of - // the statement into the literal. - let col = commented(dc("path", "TEXT"), "ends with a backslash \\"); - - let stmts = build_alter_column("mysql", &ansi(), None, "t", &col).unwrap(); - - assert!(stmts[0].ends_with("COMMENT 'ends with a backslash \\\\'")); - } - - #[test] - fn a_quote_in_a_comment_is_doubled() { - let col = commented(dc("note", "text"), "o'brien"); - - let stmts = build_alter_column("postgres", &ansi(), None, "t", &col).unwrap(); - - assert!(stmts.iter().any(|sql| sql.contains("IS 'o''brien'"))); - } - - #[test] - fn adding_a_column_with_a_comment_emits_the_comment_after_it() { - let col = commented(dc("email", "text"), "primary contact"); - - let stmts = build_add_column("postgres", None, "users", &col).unwrap(); - - assert_eq!( - stmts, - vec![ - "ALTER TABLE \"users\" ADD COLUMN \"email\" text".to_owned(), - "COMMENT ON COLUMN \"users\".\"email\" IS 'primary contact'".to_owned(), - ] - ); - } - - #[test] - fn creating_a_table_comments_every_commented_column() { - let columns = vec![ - pk(dc("id", "integer")), - commented(dc("email", "text"), "primary contact"), - dc("note", "text"), - ]; - - let stmts = build_create_table("postgres", None, "users", &columns, &[], &[]).unwrap(); - - assert_eq!(stmts.len(), 2); - assert!(stmts[0].starts_with("CREATE TABLE")); - assert_eq!(stmts[1], "COMMENT ON COLUMN \"users\".\"email\" IS 'primary contact'"); - } - - #[test] - fn mssql_adds_a_description_the_column_did_not_have() { - let col = commented(edited("email", "nvarchar(255)"), "primary contact"); - - let stmts = build_alter_column("mssql", &ansi(), Some("dbo"), "users", &col).unwrap(); - - assert_eq!(stmts.len(), 1); - assert!(stmts[0].contains("DECLARE @comment_schema sysname = N'dbo';")); - assert!(stmts[0].contains("EXEC sp_addextendedproperty @name = N'MS_Description'")); - assert!(stmts[0].contains("@value = N'primary contact'")); - assert!(stmts[0].contains("@level2type = N'COLUMN', @level2name = N'email'")); - } - - #[test] - fn mssql_updates_a_description_the_column_already_had() { - let mut col = edited_with_comment("email", "nvarchar(255)", "old text"); - col.comment = Some("new text".into()); - - let stmts = build_alter_column("mssql", &ansi(), None, "users", &col).unwrap(); - - assert!(stmts[0].contains("DECLARE @comment_schema sysname = SCHEMA_NAME();")); - assert!(stmts[0].contains("EXEC sp_updateextendedproperty")); - assert!(stmts[0].contains("@value = N'new text'")); - } - - #[test] - fn mssql_drops_a_description_the_user_cleared() { - let mut col = edited_with_comment("email", "nvarchar(255)", "old text"); - col.comment = None; - - let stmts = build_alter_column("mssql", &ansi(), None, "users", &col).unwrap(); - - assert!(stmts[0].contains("EXEC sp_dropextendedproperty @name = N'MS_Description'")); - assert!(!stmts[0].contains("@value")); - } - - #[test] - fn a_comment_change_alone_is_a_pending_op() { - let mut col = edited("email", "text"); - col.comment = Some("primary contact".into()); - - assert!(col.differs_from_original()); - assert!(col.differs_beyond_name()); - } -} diff --git a/linux/crates/core/src/statement/sql_build_error.rs b/linux/crates/core/src/statement/sql_build_error.rs index 30b66fcad3..3397788a01 100644 --- a/linux/crates/core/src/statement/sql_build_error.rs +++ b/linux/crates/core/src/statement/sql_build_error.rs @@ -1,5 +1,6 @@ use thiserror::Error; +use crate::ddl::BuildDdlError; use crate::dialect::{BindError, LiteralError}; /// Why SQL could not be built. @@ -22,3 +23,9 @@ impl From for SqlBuildError { Self::Ddl(error.to_string()) } } + +impl From for SqlBuildError { + fn from(error: BuildDdlError) -> Self { + Self::Ddl(error.to_string()) + } +} diff --git a/linux/crates/drivers/clickhouse/src/ddl.rs b/linux/crates/drivers/clickhouse/src/ddl.rs new file mode 100644 index 0000000000..8b7093f806 --- /dev/null +++ b/linux/crates/drivers/clickhouse/src/ddl.rs @@ -0,0 +1,466 @@ +use tablepro_core::column::ColumnType; +use tablepro_core::ddl::ddl_sql::default_sql; +use tablepro_core::ddl::{ + BuildDdlError, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlPlan, DraftColumn, Permission, + RefusalReason, TableChanges, TableDefinition, TableDraft, +}; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::value::Value; + +use crate::dialect::ClickHouseDialect; + +static CAPABILITIES: DdlCapabilities = DdlCapabilities { + // A table needs an engine and a sorting key the draft has no place + // for, so tables are created in the editor. + create_table: false, + rename_table: true, + add_column: true, + drop_column: true, + rename_column: true, + alter_column_type: true, + alter_column_nullability: true, + // Nullability is part of the type, so it changes by rewriting the + // column as Nullable(T) or T. + nullability_switch: false, + alter_column_default: true, + alter_primary_key: false, + alter_auto_increment: false, + // A data-skipping index needs a TYPE and a GRANULARITY the index + // dialog does not ask for. + create_index: false, + drop_index: true, + expression_index_keys: true, + partial_indexes: false, + add_foreign_key: false, + drop_foreign_key: false, + column_comments: true, + drop_table_cascade: false, +}; + +const TYPE_SUGGESTIONS: &[&str] = &[ + "String", + "Int32", + "Int64", + "UInt32", + "UInt64", + "Float64", + "Decimal(18, 2)", + "Bool", + "Date", + "DateTime", + "DateTime64(3)", + "UUID", + "LowCardinality(String)", + "Array(String)", +]; + +/// ClickHouse changes a table one ALTER at a time, and keeps no +/// transaction around them. +impl DdlDialect for ClickHouseDialect { + fn capabilities(&self) -> &DdlCapabilities { + &CAPABILITIES + } + + fn sql_dialect(&self) -> &dyn SqlDialect { + self + } + + /// A sorting or partition key column cannot be renamed, retyped or + /// dropped without rebuilding the table, and a retype needs the + /// declaration the catalogue gave. + fn column_capabilities(&self, table: &TableDefinition, column: &ColumnDefinition) -> ColumnCapabilities { + let mut permissions = ColumnCapabilities::standard(&CAPABILITIES, table, column); + let roles = column.attributes.key_roles; + if roles.sorting_key || roles.partition_key { + let key = || Permission::Refused(RefusalReason::KeyColumn); + permissions.rename = permissions.rename.and(key); + permissions.drop = permissions.drop.and(key); + permissions.alter_type = permissions.alter_type.and(key); + permissions.alter_nullability = permissions.alter_nullability.and(key); + } + if column.info.column_type.catalog.is_none() { + let unreadable = || Permission::Refused(RefusalReason::UnreadableDefinition); + permissions.alter_nullability = permissions.alter_nullability.and(unreadable); + } + permissions + } + + fn type_suggestions(&self) -> &'static [&'static str] { + TYPE_SUGGESTIONS + } + + fn default_column_type(&self) -> &'static str { + "String" + } + + fn plan_create_table(&self, draft: &TableDraft) -> Result { + draft.check_create(self)?; + Err(BuildDdlError::Unsupported( + tablepro_core::ddl::DdlOperation::CreateTable, + )) + } + + fn plan_alter_table(&self, original: &TableDefinition, changes: &TableChanges) -> Result { + changes.check(self, original)?; + let mut statements = Vec::new(); + let table = changes.target(&original.table); + // ClickHouse has no ALTER TABLE ... RENAME TO. The new name keeps + // the database, or the table would move to the session's. + if changes.renamed_table.is_some() { + statements.push(format!( + "RENAME TABLE {} TO {}", + self.qualify(&original.table), + self.qualify(&table) + )); + } + let qualified = self.qualify(&table); + let alter = |action: String| format!("ALTER TABLE {qualified} {action}"); + for index in &changes.dropped_indexes { + statements.push(alter(format!("DROP INDEX {}", self.quote_identifier(&index.name)))); + } + for column in &changes.dropped_columns { + statements.push(alter(format!("DROP COLUMN {}", self.quote_identifier(column.name())))); + } + for rename in &changes.renamed_columns { + statements.push(alter(format!( + "RENAME COLUMN {} TO {}", + self.quote_identifier(&rename.from), + self.quote_identifier(&rename.to) + ))); + } + for alteration in &changes.altered_columns { + let draft = &alteration.draft; + let name = self.quote_identifier(&draft.name); + let changed = alteration.changed; + if changed.column_type || changed.nullability { + let resolved = draft.column_type.resolve(self, &draft.name)?; + statements.push(alter(format!( + "MODIFY COLUMN {name} {}", + with_nullability(&resolved.sql, draft.nullable) + ))); + } + if changed.default { + let column_type = draft.column_type.column_type(self, &draft.name)?; + statements.push(alter( + match default_sql(self, &draft.name, &draft.default, &column_type)? { + Some(default) => format!("MODIFY COLUMN {name} DEFAULT {default}"), + None => format!("MODIFY COLUMN {name} REMOVE DEFAULT"), + }, + )); + } + if changed.comment { + statements.push(alter(format!( + "COMMENT COLUMN {name} {}", + self.comment(draft.comment_text()) + ))); + } + } + for column in &changes.added_columns { + statements.push(alter(format!("ADD COLUMN {}", self.column_definition(column)?))); + } + Ok(DdlPlan::from_sql(statements)) + } + + /// SHOW CREATE TABLE, which carries the engine, the keys and the + /// settings the definition does not model. + fn create_table_script(&self, definition: &TableDefinition) -> Result { + definition + .source + .as_ref() + .map(|source| DdlPlan::from_sql([source.create_statement.clone()])) + .ok_or(BuildDdlError::NoTableSource) + } +} + +impl ClickHouseDialect { + fn column_definition(&self, column: &DraftColumn) -> Result { + let resolved = column.column_type.resolve(self, &column.name)?; + let mut parts = vec![ + self.quote_identifier(&column.name), + with_nullability(&resolved.sql, column.nullable), + ]; + if let Some(default) = default_sql(self, &column.name, &column.default, &resolved.column_type)? { + parts.push(format!("DEFAULT {default}")); + } + if let Some(comment) = column.comment_text() { + parts.push(format!("COMMENT {}", self.comment(Some(comment)))); + } + Ok(parts.join(" ")) + } + + fn comment(&self, text: Option<&str>) -> String { + let value = Value::Text(text.unwrap_or_default().to_owned()); + SqlDialect::literal(self, &value, &ColumnType::untyped_text("String")).unwrap_or_else(|_| "''".to_owned()) + } +} + +/// `declaration` as Nullable or not. LowCardinality has to stay +/// outermost, so the Nullable goes inside it. +fn with_nullability(declaration: &str, nullable: bool) -> String { + let declaration = declaration.trim(); + if let Some(inner) = wrapped(declaration, "LowCardinality") { + return format!("LowCardinality({})", with_nullability(inner, nullable)); + } + match (wrapped(declaration, "Nullable"), nullable) { + (Some(_), true) | (None, false) => declaration.to_owned(), + (Some(inner), false) => inner.trim().to_owned(), + (None, true) => format!("Nullable({declaration})"), + } +} + +fn wrapped<'a>(declaration: &'a str, wrapper: &str) -> Option<&'a str> { + let rest = declaration.get(wrapper.len()..)?; + let head = declaration.get(..wrapper.len())?; + (head.eq_ignore_ascii_case(wrapper) && rest.starts_with('(') && rest.ends_with(')')) + .then(|| rest.get(1..rest.len() - 1)) + .flatten() +} + +#[cfg(test)] +mod tests { + use tablepro_core::ServerInfo; + use tablepro_core::column::{ColumnDefault, ColumnInfo}; + use tablepro_core::ddl::{ + ColumnAttributes, ColumnKeyRoles, DdlOperation, DraftType, DropTableOptions, TableSource, diff_table, + }; + use tablepro_core::meta::TableRef; + + use super::*; + + fn dialect() -> ClickHouseDialect { + ClickHouseDialect::new(&ServerInfo::new("ClickHouse", "25.3", 25, 3, 0)) + } + + fn column(name: &str, declaration: &str, sorting_key: bool) -> ColumnDefinition { + let column_type = dialect().parse_type(declaration).expect("a ClickHouse type"); + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + nullable: declaration.starts_with("Nullable"), + column_type, + primary_key: sorting_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes { + key_roles: ColumnKeyRoles { + sorting_key, + partition_key: false, + }, + ..ColumnAttributes::default() + }, + ) + } + + fn events() -> TableDefinition { + TableDefinition { + table: TableRef::new(Some("analytics".to_owned()), "events"), + columns: vec![ + column("id", "UInt64", true), + column("label", "String", false), + column("note", "Nullable(String)", false), + ], + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } + } + + fn alter(original: &TableDefinition, edit: impl FnOnce(&mut TableDraft)) -> Result { + let mut draft = TableDraft::from_definition(original); + edit(&mut draft); + dialect().plan_alter_table(original, &diff_table(original, &draft)) + } + + fn sql(plan: Result) -> Vec { + plan.expect("the change plans") + .sql() + .into_iter() + .map(str::to_owned) + .collect() + } + + #[test] + fn clickhouse_capabilities_refuse_key_columns() { + let table = events(); + + let key = dialect().column_capabilities(&table, &table.columns[0]); + let plain = dialect().column_capabilities(&table, &table.columns[1]); + + for permission in [&key.rename, &key.drop, &key.alter_type, &key.alter_nullability] { + assert_eq!(*permission, Permission::Refused(RefusalReason::KeyColumn)); + } + assert_eq!(key.alter_default, Permission::Allowed); + assert_eq!(plain.rename, Permission::Allowed); + assert_eq!(plain.alter_type, Permission::Allowed); + assert!(!DdlDialect::capabilities(&dialect()).create_table); + assert!(!DdlDialect::capabilities(&dialect()).nullability_switch); + assert!(matches!( + alter(&table, |draft| draft.columns[0].name = "event_id".to_owned()), + Err(BuildDdlError::Refused { + reason: RefusalReason::KeyColumn, + .. + }) + )); + } + + /// ClickHouse has no foreign keys, so a reference can only come from + /// a definition built elsewhere; the rule still holds. + fn referenced(table: &mut TableDefinition, column: &str) { + table.inbound_foreign_keys = vec![tablepro_core::ddl::InboundForeignKey { + name: "visits_event_fk".to_owned(), + table: TableRef::new(Some("analytics".to_owned()), "visits"), + columns: vec!["event_id".to_owned()], + referenced_columns: vec![column.to_owned()], + }]; + } + + #[test] + fn primary_key_referenced_by_foreign_key_is_refused() { + let mut table = events(); + table.primary_key = Some(tablepro_core::ddl::PrimaryKeyConstraint { + name: None, + columns: vec!["id".to_owned()], + }); + referenced(&mut table, "id"); + + let permissions = dialect().column_capabilities(&table, &table.columns[1]); + + assert_eq!( + permissions.alter_primary_key, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec![ + "visits_event_fk".to_owned() + ])) + ); + } + + #[test] + fn drop_of_referenced_column_is_refused() { + let mut table = events(); + referenced(&mut table, "label"); + + let plan = alter(&table, |draft| draft.columns.retain(|column| column.name != "label")); + + assert!( + matches!( + plan, + Err(BuildDdlError::Refused { + operation: DdlOperation::DropColumn, + reason: RefusalReason::ReferencedByForeignKeys(_), + .. + }) + ), + "{plan:?}" + ); + } + + #[test] + fn a_table_is_never_created_from_a_draft() { + let mut draft = TableDraft::new(TableRef::unqualified("t")); + draft + .columns + .push(DraftColumn::new("id", DraftType::Typed("UInt64".to_owned()))); + + assert_eq!( + dialect().plan_create_table(&draft), + Err(BuildDdlError::Unsupported(DdlOperation::CreateTable)) + ); + } + + #[test] + fn clickhouse_rename_uses_rename_table_in_the_same_database() { + let plan = alter(&events(), |draft| draft.table.name = "visits".to_owned()); + + assert_eq!(sql(plan), ["RENAME TABLE `analytics`.`events` TO `analytics`.`visits`"]); + } + + #[test] + fn plan_alter_table_nullability_type_and_default() { + let plan = alter(&events(), |draft| { + let label = &mut draft.columns[1]; + label.nullable = true; + label.default = ColumnDefault::Literal(Value::Text("it's".to_owned())); + draft.columns[2].column_type = DraftType::Typed("LowCardinality(String)".to_owned()); + draft.columns[2].comment = Some("free text".to_owned()); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE `analytics`.`events` MODIFY COLUMN `label` Nullable(String)", + "ALTER TABLE `analytics`.`events` MODIFY COLUMN `label` DEFAULT 'it\\'s'", + "ALTER TABLE `analytics`.`events` MODIFY COLUMN `note` LowCardinality(Nullable(String))", + "ALTER TABLE `analytics`.`events` COMMENT COLUMN `note` 'free text'", + ] + ); + } + + #[test] + fn a_removed_default_and_a_rename_and_an_added_column() { + let mut table = events(); + table.columns[1].info.default = ColumnDefault::Literal(Value::Text("x".to_owned())); + + let plan = alter(&table, |draft| { + draft.columns[1].default = ColumnDefault::None; + draft.columns[2].name = "remark".to_owned(); + draft.columns[2].nullable = false; + draft + .columns + .push(DraftColumn::new("tag", DraftType::Typed("String".to_owned()))); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE `analytics`.`events` RENAME COLUMN `note` TO `remark`", + "ALTER TABLE `analytics`.`events` MODIFY COLUMN `label` REMOVE DEFAULT", + "ALTER TABLE `analytics`.`events` MODIFY COLUMN `remark` String", + "ALTER TABLE `analytics`.`events` ADD COLUMN `tag` Nullable(String)", + ] + ); + } + + #[test] + fn the_script_is_the_servers_own_text() { + let mut table = events(); + + assert_eq!(dialect().create_table_script(&table), Err(BuildDdlError::NoTableSource)); + table.source = Some(TableSource { + create_statement: "CREATE TABLE analytics.events (`id` UInt64) ENGINE = MergeTree ORDER BY id".to_owned(), + dependents: Vec::new(), + }); + assert_eq!( + sql(dialect().create_table_script(&table)), + ["CREATE TABLE analytics.events (`id` UInt64) ENGINE = MergeTree ORDER BY id"] + ); + assert_eq!( + sql(dialect().plan_drop_table(&table.table, DropTableOptions::default())), + ["DROP TABLE `analytics`.`events`"] + ); + } + + #[test] + fn nullability_wraps_inside_low_cardinality() { + assert_eq!(with_nullability("String", true), "Nullable(String)"); + assert_eq!(with_nullability("Nullable(String)", false), "String"); + assert_eq!(with_nullability("nullable(Int64)", true), "nullable(Int64)"); + assert_eq!( + with_nullability("LowCardinality(Nullable(String))", false), + "LowCardinality(String)" + ); + } + + #[test] + fn every_type_suggestion_parses() { + let dialect = dialect(); + + for suggestion in dialect.type_suggestions() { + assert!(dialect.parse_type(suggestion).is_ok(), "{suggestion}"); + } + assert!(dialect.parse_type(dialect.default_column_type()).is_ok()); + } +} diff --git a/linux/crates/drivers/clickhouse/src/dialect.rs b/linux/crates/drivers/clickhouse/src/dialect.rs index 34800f8067..68a8c6c1a1 100644 --- a/linux/crates/drivers/clickhouse/src/dialect.rs +++ b/linux/crates/drivers/clickhouse/src/dialect.rs @@ -2,7 +2,7 @@ use tablepro_core::ServerInfo; use tablepro_core::column::{ BoolStorage, ColumnInfo, ColumnKind, ColumnType, FloatWidth, IntegerWidth, SqlExpression, TemporalZone, TextLength, }; -use tablepro_core::ddl::{DdlCapabilities, DdlDialect}; +use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ BindError, BindTarget, DialectCapabilities, KeysetDirection, LikeCase, LikeForm, LiteralError, Placeholder, SqlDialect, @@ -50,40 +50,6 @@ impl ClickHouseDialect { } } -static CLICKHOUSE_DDL: ClickHouseDdl = ClickHouseDdl { - capabilities: DdlCapabilities { - create_table: true, - rename_table: true, - add_column: true, - drop_column: true, - rename_column: true, - alter_column_type: true, - alter_column_nullability: true, - nullability_switch: false, - alter_column_default: true, - alter_primary_key: false, - alter_auto_increment: false, - create_index: true, - drop_index: true, - expression_index_keys: true, - partial_indexes: false, - add_foreign_key: false, - drop_foreign_key: false, - drop_table_cascade: false, - }, -}; - -#[derive(Debug)] -struct ClickHouseDdl { - capabilities: DdlCapabilities, -} - -impl DdlDialect for ClickHouseDdl { - fn capabilities(&self) -> &DdlCapabilities { - &self.capabilities - } -} - impl SqlDialect for ClickHouseDialect { fn capabilities(&self) -> DialectCapabilities { // A mutation reports nothing useful about how many rows it @@ -190,7 +156,7 @@ impl SqlDialect for ClickHouseDialect { } fn ddl(&self) -> Option<&dyn DdlDialect> { - Some(&CLICKHOUSE_DDL) + Some(self) } /// The column alone: the FROM names the table as `db.table`, which a @@ -519,7 +485,7 @@ mod tests { fn writes_are_mutations_and_counts_are_not_exact() { let dialect = dialect(); - assert!(!dialect.capabilities().exact_row_counts); + assert!(!SqlDialect::capabilities(&dialect).exact_row_counts); assert_eq!( dialect.update_statement("t", "a = 1", "id = 2"), "ALTER TABLE t UPDATE a = 1 WHERE id = 2" diff --git a/linux/crates/drivers/clickhouse/src/lib.rs b/linux/crates/drivers/clickhouse/src/lib.rs index c80dfb56da..5cf6138897 100644 --- a/linux/crates/drivers/clickhouse/src/lib.rs +++ b/linux/crates/drivers/clickhouse/src/lib.rs @@ -16,6 +16,7 @@ mod clickhouse_type; mod client_factory; mod commit_resolution; mod connection; +mod ddl; mod dialect; mod driver; mod editor_run; diff --git a/linux/crates/drivers/clickhouse/tests/integration/conformance_engine.rs b/linux/crates/drivers/clickhouse/tests/integration/conformance_engine.rs index 43e99625b5..632dad6a07 100644 --- a/linux/crates/drivers/clickhouse/tests/integration/conformance_engine.rs +++ b/linux/crates/drivers/clickhouse/tests/integration/conformance_engine.rs @@ -5,13 +5,17 @@ use std::time::Duration; use async_trait::async_trait; use drivers_clickhouse::ClickhouseDriver; +use tablepro_core::ddl::DraftColumn; +use tablepro_core::ddl::ddl_sql::default_sql; +use tablepro_core::dialect::SqlDialect; use tablepro_core::{ ConnectOptions, DatabaseDriver, LivenessPolicy, NetworkEndpoint, TableRef, TlsConfig, WorkspaceSettings, }; use tablepro_test_fixtures::ClickHouseFixture; use tablepro_test_support::FaultTransport; use tablepro_test_support::conformance::{ - ConformanceEngine, ConformanceFailure, NetworkConformanceEngine, TypedConformanceEngine, UnsupportedTypeCase, + ConformanceDdlEngine, ConformanceEngine, ConformanceFailure, NetworkConformanceEngine, TypedConformanceEngine, + UnsupportedTypeCase, }; use crate::support::{DATABASE, direct, options_over, password, quoted}; @@ -119,6 +123,47 @@ impl TypedConformanceEngine for ClickHouseEngine { } } +impl ConformanceDdlEngine for ClickHouseEngine { + fn integer_type(&self) -> &'static str { + "Int64" + } + + fn text_type(&self) -> &'static str { + "String" + } + + fn timestamp_type(&self) -> &'static str { + "DateTime" + } + + fn current_timestamp_expression(&self) -> &'static str { + "now()" + } + + /// The DDL dialect creates no ClickHouse tables, since a table needs + /// an engine and a sorting key. + fn create_table_sql(&self, dialect: &dyn SqlDialect, table: &TableRef, columns: &[DraftColumn]) -> Option { + let mut definitions = Vec::with_capacity(columns.len()); + for column in columns { + let declaration = column.column_type.text(); + let declaration = match column.nullable { + true => format!("Nullable({declaration})"), + false => declaration.to_owned(), + }; + let column_type = column.column_type.column_type(dialect, &column.name).ok()?; + let default = default_sql(dialect, &column.name, &column.default, &column_type).ok()?; + let default = default.map(|sql| format!(" DEFAULT {sql}")).unwrap_or_default(); + definitions.push(format!("{} {declaration}{default}", quoted(&column.name))); + } + Some(format!( + "CREATE TABLE {}.{} ({}) ENGINE = MergeTree ORDER BY id", + quoted(DATABASE), + quoted(&table.name), + definitions.join(", ") + )) + } +} + mod suite { use super::ClickHouseEngine; @@ -131,4 +176,9 @@ mod suite { unsupported_types: true, bounded_ddl: false, } + + tablepro_test_support::ddl_conformance_suite! { + engine: ClickHouseEngine, + docker: true, + } } diff --git a/linux/crates/drivers/mssql/src/ddl.rs b/linux/crates/drivers/mssql/src/ddl.rs index 6b0a7c829b..b554da2d95 100644 --- a/linux/crates/drivers/mssql/src/ddl.rs +++ b/linux/crates/drivers/mssql/src/ddl.rs @@ -1,99 +1,337 @@ -use tablepro_core::ddl::{ColumnDefinition, DdlCapabilities, DdlDialect}; +use tablepro_core::column::ColumnDefault; +use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, default_sql, foreign_key_sql, parenthesized}; +use tablepro_core::ddl::{ + BuildDdlError, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlPlan, DraftColumn, + GeneratedStorage, IdentityStyle, Permission, ReferentialAction, RefusalReason, TableChanges, TableDefinition, + TableDraft, +}; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; -use crate::dialect::national_literal; - -/// What the structure tab can change on SQL Server, and the parts of -/// its DDL only SQL Server needs. -#[derive(Debug)] -pub struct MssqlDdl { - capabilities: DdlCapabilities, -} - -pub(crate) static MSSQL_DDL: MssqlDdl = MssqlDdl { - capabilities: DdlCapabilities { - create_table: true, - rename_table: true, - add_column: true, - drop_column: true, - rename_column: true, - alter_column_type: true, - alter_column_nullability: true, - nullability_switch: true, - alter_column_default: true, - alter_primary_key: true, - // IDENTITY cannot be added to or taken from an existing column. - alter_auto_increment: false, - create_index: true, - drop_index: true, - // An index key is a column; an expression needs a computed one. - expression_index_keys: false, - partial_indexes: true, - add_foreign_key: true, - drop_foreign_key: true, - drop_table_cascade: false, - }, +use crate::dialect::{MssqlDialect, national_literal}; + +static CAPABILITIES: DdlCapabilities = DdlCapabilities { + create_table: true, + rename_table: true, + add_column: true, + drop_column: true, + rename_column: true, + alter_column_type: true, + alter_column_nullability: true, + nullability_switch: true, + alter_column_default: true, + alter_primary_key: true, + // IDENTITY cannot be added to or taken from an existing column. + alter_auto_increment: false, + create_index: true, + drop_index: true, + // An index key is a column; an expression needs a computed one. + expression_index_keys: false, + partial_indexes: true, + add_foreign_key: true, + drop_foreign_key: true, + column_comments: true, + drop_table_cascade: false, }; -impl DdlDialect for MssqlDdl { +/// T-SQL has no RESTRICT; the engine spells that behaviour NO ACTION. +const REFERENTIAL_ACTIONS: &[ReferentialAction] = &[ + ReferentialAction::NoAction, + ReferentialAction::Cascade, + ReferentialAction::SetNull, + ReferentialAction::SetDefault, +]; + +const TYPE_SUGGESTIONS: &[&str] = &[ + "int", + "bigint", + "smallint", + "tinyint", + "bit", + "nvarchar(255)", + "nvarchar(max)", + "varchar(255)", + "decimal(18, 2)", + "float", + "real", + "money", + "date", + "time", + "datetime2", + "datetimeoffset", + "uniqueidentifier", + "varbinary(max)", +]; + +/// SQL Server changes a table one statement at a time, inside the +/// write's transaction. A default is a constraint with a name of its +/// own, so a column whose default changes or goes away has that +/// constraint dropped by name first. +impl DdlDialect for MssqlDialect { fn capabilities(&self) -> &DdlCapabilities { - &self.capabilities + &CAPABILITIES } - /// `sp_rename` takes the names as string literals, not identifiers. - /// They are national literals, so a name outside the server's code - /// page survives. - fn rename_table(&self, dialect: &dyn SqlDialect, table: &TableRef, new_name: &str) -> String { - format!( - "EXEC sp_rename {}, {}", - national_literal(&dialect.qualify(table)), - national_literal(new_name) - ) + fn sql_dialect(&self) -> &dyn SqlDialect { + self } - /// The column is named through its bracketed table, because - /// `sp_rename` splits the first argument on every unquoted `.`. - fn rename_column(&self, dialect: &dyn SqlDialect, table: &TableRef, column: &str, new_name: &str) -> String { - let object = format!("{}.{}", dialect.qualify(table), dialect.quote_identifier(column)); - format!( - "EXEC sp_rename {}, {}, N'COLUMN'", - national_literal(&object), - national_literal(new_name) - ) + /// ALTER COLUMN restates the type with the nullability, so a column + /// whose type the catalogue did not spell cannot take one. + fn column_capabilities(&self, table: &TableDefinition, column: &ColumnDefinition) -> ColumnCapabilities { + let mut permissions = ColumnCapabilities::standard(&CAPABILITIES, table, column); + if column.info.column_type.catalog.is_none() { + permissions.alter_nullability = permissions + .alter_nullability + .and(|| Permission::Refused(RefusalReason::UnreadableDefinition)); + } + permissions + } + + fn type_suggestions(&self) -> &'static [&'static str] { + TYPE_SUGGESTIONS + } + + fn default_column_type(&self) -> &'static str { + "nvarchar(255)" } - /// A default here is a constraint with a name of its own, which the - /// catalogue gave. The name is spliced as a bracketed identifier, - /// never through dynamic SQL, so a name holding `]` or a quote is - /// just a name. `None` where the column has no default constraint. - fn drop_column_default( - &self, - dialect: &dyn SqlDialect, - table: &TableRef, - _column: &str, - original: &ColumnDefinition, - ) -> Option { + fn referential_actions(&self) -> &'static [ReferentialAction] { + REFERENTIAL_ACTIONS + } + + fn plan_create_table(&self, draft: &TableDraft) -> Result { + draft.check_create(self)?; + let table = TableRef::new(draft.table.schema.clone(), draft.table.name.trim()); + let mut definitions = draft + .columns + .iter() + .map(|column| self.column_definition(column)) + .collect::, _>>()?; + let key: Vec = draft.primary_key().into_iter().map(str::to_owned).collect(); + if !key.is_empty() { + definitions.push(format!("PRIMARY KEY ({})", column_list(self, &key))); + } + definitions.extend(draft.foreign_keys.iter().map(|key| foreign_key_sql(self, &table, key))); + let mut statements = vec![format!( + "CREATE TABLE {} (\n {}\n)", + self.qualify(&table), + definitions.join(",\n ") + )]; + statements.extend( + draft + .indexes + .iter() + .filter(|index| !index.primary) + .map(|index| create_index_sql(self, &table, index)), + ); + for column in &draft.columns { + if let Some(comment) = column.comment_text() { + statements.push(column_comment(&table, &column.name, false, Some(comment))); + } + } + Ok(DdlPlan::from_sql(statements)) + } + + fn plan_alter_table(&self, original: &TableDefinition, changes: &TableChanges) -> Result { + changes.check(self, original)?; + let mut statements = Vec::new(); + if let Some(name) = &changes.renamed_table { + statements.push(format!( + "EXEC sp_rename {}, {}", + national_literal(&self.qualify(&original.table)), + national_literal(name) + )); + } + let table = changes.target(&original.table); + let qualified = self.qualify(&table); + let alter = |action: String| format!("ALTER TABLE {qualified} {action}"); + + for key in &changes.dropped_foreign_keys { + statements.push(alter(format!("DROP CONSTRAINT {}", self.quote_identifier(&key.name)))); + } + for index in &changes.dropped_indexes { + statements.push(format!( + "DROP INDEX {} ON {qualified}", + self.quote_identifier(&index.name) + )); + } + let old_key = changes.primary_key.as_ref().and_then(|change| change.from.as_ref()); + if let Some(name) = old_key.and_then(|key| key.name.as_ref()) { + statements.push(alter(format!("DROP CONSTRAINT {}", self.quote_identifier(name)))); + } + for column in &changes.dropped_columns { + statements.extend(self.drop_default(&table, column)); + statements.push(alter(format!("DROP COLUMN {}", self.quote_identifier(column.name())))); + } + for rename in &changes.renamed_columns { + let object = format!("{}.{}", qualified, self.quote_identifier(&rename.from)); + statements.push(format!( + "EXEC sp_rename {}, {}, N'COLUMN'", + national_literal(&object), + national_literal(&rename.to) + )); + } + for alteration in &changes.altered_columns { + let draft = &alteration.draft; + let name = self.quote_identifier(&draft.name); + let changed = alteration.changed; + // A column bound to a default cannot change type, so the + // drop comes first. + if changed.default { + statements.extend(self.drop_default(&table, &alteration.original)); + } + // T-SQL reads an omitted NULL or NOT NULL as NULL, so a type + // change restates the nullability. + if changed.column_type || changed.nullability { + let resolved = draft.column_type.resolve(self, &draft.name)?; + let nullability = if draft.nullable { "NULL" } else { "NOT NULL" }; + statements.push(alter(format!("ALTER COLUMN {name} {} {nullability}", resolved.sql))); + } + if changed.default { + let column_type = draft.column_type.column_type(self, &draft.name)?; + if let Some(default) = default_sql(self, &draft.name, &draft.default, &column_type)? { + statements.push(alter(format!( + "ADD DEFAULT {} FOR {name}", + default_clause(&draft.default, &default) + ))); + } + } + if changed.comment { + let had_comment = alteration + .original + .info + .comment + .as_deref() + .is_some_and(|text| !text.is_empty()); + statements.push(column_comment(&table, &draft.name, had_comment, draft.comment_text())); + } + } + for column in &changes.added_columns { + statements.push(alter(format!("ADD {}", self.column_definition(column)?))); + if let Some(comment) = column.comment_text() { + statements.push(column_comment(&table, &column.name, false, Some(comment))); + } + } + if let Some(change) = changes.primary_key.as_ref().filter(|change| !change.to.is_empty()) { + let constraint = old_key + .and_then(|key| key.name.as_ref()) + .map(|name| format!("CONSTRAINT {} ", self.quote_identifier(name))) + .unwrap_or_default(); + statements.push(alter(format!( + "ADD {constraint}PRIMARY KEY ({})", + column_list(self, &change.to) + ))); + } + statements.extend( + changes + .added_indexes + .iter() + .map(|index| create_index_sql(self, &table, index)), + ); + for key in &changes.added_foreign_keys { + statements.push(alter(format!("ADD {}", foreign_key_sql(self, &table, key)))); + } + Ok(DdlPlan::from_sql(statements)) + } +} + +impl MssqlDialect { + /// `name type [IDENTITY(1,1)] NULL|NOT NULL [DEFAULT (..)]`, or a + /// computed column read from the catalogue as `name AS (..)`. + fn column_definition(&self, column: &DraftColumn) -> Result { + let name = self.quote_identifier(&column.name); + let attributes = column.original.as_ref().map(|original| &original.attributes); + if let Some(generated) = attributes.and_then(|attributes| attributes.generated.as_ref()) { + let persisted = match generated.storage { + GeneratedStorage::Stored => " PERSISTED", + GeneratedStorage::Virtual => "", + }; + return Ok(format!( + "{name} AS {}{persisted}", + parenthesized(generated.expression.as_sql()) + )); + } + let resolved = column.column_type.resolve(self, &column.name)?; + let mut parts = vec![name, resolved.sql.clone()]; + let identity = column.auto_increment + || attributes.is_some_and(|attributes| attributes.identity == IdentityStyle::MssqlIdentity); + if identity { + parts.push("IDENTITY(1,1)".to_owned()); + } + parts.push(if column.nullable { "NULL" } else { "NOT NULL" }.to_owned()); + // An identity column cannot carry a default. + if !identity && let Some(default) = default_sql(self, &column.name, &column.default, &resolved.column_type)? { + parts.push(format!("DEFAULT {}", default_clause(&column.default, &default))); + } + Ok(parts.join(" ")) + } + + /// The name comes from the catalogue and is spliced as a bracketed + /// identifier, never through dynamic SQL, so a name holding `]` or a + /// quote is just a name. `None` where the column has no default + /// constraint. + fn drop_default(&self, table: &TableRef, original: &ColumnDefinition) -> Option { let name = original.attributes.default_constraint_name.as_deref()?; Some(format!( "ALTER TABLE {} DROP CONSTRAINT {}", - dialect.qualify(table), - dialect.quote_identifier(name) + self.qualify(table), + self.quote_identifier(name) )) } } +fn default_clause(default: &ColumnDefault, sql: &str) -> String { + match default { + ColumnDefault::Expression(_) => parenthesized(sql), + _ => format!("({sql})"), + } +} + +/// SQL Server keeps a column description in an extended property named +/// `MS_Description`, which is added, updated and dropped by three +/// procedures, and which one applies depends on whether the column had +/// a description. The schema goes through a variable because a procedure +/// argument cannot be a function call. +fn column_comment(table: &TableRef, column: &str, had_comment: bool, comment: Option<&str>) -> String { + let schema = match table.schema.as_deref().filter(|name| !name.is_empty()) { + Some(name) => national_literal(name), + None => "SCHEMA_NAME()".to_owned(), + }; + let levels = format!( + "@level0type = N'SCHEMA', @level0name = @comment_schema, @level1type = N'TABLE', @level1name = {}, \ + @level2type = N'COLUMN', @level2name = {}", + national_literal(&table.name), + national_literal(column) + ); + let head = format!("DECLARE @comment_schema sysname = {schema}; "); + match (comment, had_comment) { + (Some(text), had_comment) => { + let procedure = if had_comment { + "sp_updateextendedproperty" + } else { + "sp_addextendedproperty" + }; + format!( + "{head}EXEC {procedure} @name = N'MS_Description', @value = {}, {levels}", + national_literal(text) + ) + } + (None, _) => format!("{head}EXEC sp_dropextendedproperty @name = N'MS_Description', {levels}"), + } +} + #[cfg(test)] mod tests { - use tablepro_core::column::{ColumnDefault, ColumnInfo, SqlExpression}; - use tablepro_core::ddl::ColumnAttributes; - use tablepro_core::sql_ddl::{ - DraftColumn, StructureOp, build_alter_column, build_rename_column, build_rename_table, materialize_ops, + use tablepro_core::column::{ColumnInfo, SqlExpression}; + use tablepro_core::ddl::{ + ColumnAttributes, DdlOperation, DraftType, DropTableOptions, InboundForeignKey, PrimaryKeyConstraint, + diff_table, }; + use tablepro_core::value::Value; use super::*; use crate::catalog::SqlServerType; - use crate::dialect::MssqlDialect; fn dialect() -> MssqlDialect { MssqlDialect::new(16) @@ -122,150 +360,291 @@ mod tests { ) } - fn table() -> TableRef { - TableRef::new(Some("dbo".to_owned()), "orders") + fn orders() -> TableDefinition { + let mut id = column("id", "int", None); + id.info.primary_key = true; + id.info.nullable = false; + TableDefinition { + table: TableRef::new(Some("dbo".to_owned()), "orders"), + columns: vec![ + id, + column("status", "int", Some("DF]x")), + column("note", "nvarchar(20)", None), + ], + primary_key: Some(PrimaryKeyConstraint { + name: Some("PK_orders".to_owned()), + columns: vec!["id".to_owned()], + }), + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } } - fn alter(draft: &DraftColumn) -> Vec { - build_alter_column("mssql", &dialect(), Some("dbo"), "orders", draft).expect("an alter") + fn alter(original: &TableDefinition, edit: impl FnOnce(&mut TableDraft)) -> Result { + let mut draft = TableDraft::from_definition(original); + edit(&mut draft); + dialect().plan_alter_table(original, &diff_table(original, &draft)) } - #[test] - fn mssql_default_drop_is_static_and_bracket_escaped() { - let dialect = dialect(); - - let drop = MSSQL_DDL - .drop_column_default(&dialect, &table(), "status", &column("status", "int", Some("DF]x"))) - .unwrap(); + fn sql(plan: Result) -> Vec { + plan.expect("the change plans") + .sql() + .into_iter() + .map(str::to_owned) + .collect() + } - assert_eq!(drop, "ALTER TABLE [dbo].[orders] DROP CONSTRAINT [DF]]x]"); - assert!(!drop.contains("DECLARE")); - assert!(!drop.contains("EXEC(")); + fn typed(name: &str, declaration: &str) -> DraftColumn { + DraftColumn::new(name, DraftType::Typed(declaration.to_owned())) } #[test] - fn mssql_default_drop_absent_without_constraint() { + fn plan_create_table_simple() { + let mut draft = TableDraft::new(TableRef::new(Some("dbo".to_owned()), "notes")); + let mut id = typed("id", "bigint"); + id.primary_key = true; + id.nullable = false; + id.auto_increment = true; + let mut body = typed("body", "nvarchar(255)"); + body.nullable = false; + body.default = ColumnDefault::Literal(Value::Text("it's".to_owned())); + let mut created = typed("created_at", "datetime2"); + created.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("getdate()")); + draft.columns = vec![id, body, created]; + assert_eq!( - MSSQL_DDL.drop_column_default(&dialect(), &table(), "status", &column("status", "int", None)), - None + sql(dialect().plan_create_table(&draft)), + [ + "CREATE TABLE [dbo].[notes] (\n [id] bigint IDENTITY(1,1) NOT NULL,\n [body] nvarchar(255) NOT NULL \ + DEFAULT (N'it''s'),\n [created_at] datetime2 NULL DEFAULT (getdate()),\n PRIMARY KEY ([id])\n)" + ] ); } #[test] - fn mssql_sp_rename_uses_national_literals() { - let dialect = dialect(); + fn plan_create_table_composite_key_index_and_foreign_key() { + let mut draft = TableDraft::new(TableRef::new(Some("dbo".to_owned()), "lines")); + let mut order = typed("order_id", "int"); + order.primary_key = true; + order.nullable = false; + let mut line = typed("line", "int"); + line.primary_key = true; + line.nullable = false; + let mut sku = typed("sku", "nvarchar(40)"); + sku.comment = Some("stock unit".to_owned()); + draft.columns = vec![order, line, sku]; + draft.indexes = vec![tablepro_core::meta::IndexInfo { + name: "by_sku".to_owned(), + keys: vec![tablepro_core::meta::IndexKey::Column { + name: "sku".to_owned(), + descending: false, + }], + unique: false, + primary: false, + predicate: Some("([sku] IS NOT NULL)".to_owned()), + }]; + draft.foreign_keys = vec![tablepro_core::ForeignKeyInfo { + name: "lines_order_fk".to_owned(), + columns: vec!["order_id".to_owned()], + ref_schema: None, + ref_table: "orders".to_owned(), + ref_columns: vec!["id".to_owned()], + on_delete: Some(ReferentialAction::Cascade), + on_update: None, + }]; assert_eq!( - build_rename_table("mssql", &dialect, Some("dbo"), "orders", "đơn hàng").unwrap(), - "EXEC sp_rename N'[dbo].[orders]', N'đơn hàng'" + sql(dialect().plan_create_table(&draft)), + [ + "CREATE TABLE [dbo].[lines] (\n [order_id] int NOT NULL,\n [line] int NOT NULL,\n [sku] \ + nvarchar(40) NULL,\n PRIMARY KEY ([order_id], [line]),\n CONSTRAINT [lines_order_fk] FOREIGN KEY \ + ([order_id]) REFERENCES [dbo].[orders] ([id]) ON DELETE CASCADE\n)", + "CREATE INDEX [by_sku] ON [dbo].[lines] ([sku]) WHERE ([sku] IS NOT NULL)", + "DECLARE @comment_schema sysname = N'dbo'; EXEC sp_addextendedproperty @name = N'MS_Description', \ + @value = N'stock unit', @level0type = N'SCHEMA', @level0name = @comment_schema, @level1type = \ + N'TABLE', @level1name = N'lines', @level2type = N'COLUMN', @level2name = N'sku'", + ] ); + } + + #[test] + fn plan_alter_table_nullability_type_and_default() { + let plan = alter(&orders(), |draft| { + let status = &mut draft.columns[1]; + status.column_type = DraftType::Typed("bigint".to_owned()); + status.nullable = false; + status.default = ColumnDefault::Literal(Value::Int(1)); + }); + assert_eq!( - build_rename_column("mssql", &dialect, Some("dbo"), "orders", "o'brien", "new'name").unwrap(), - "EXEC sp_rename N'[dbo].[orders].[o''brien]', N'new''name', N'COLUMN'" + sql(plan), + [ + "ALTER TABLE [dbo].[orders] DROP CONSTRAINT [DF]]x]", + "ALTER TABLE [dbo].[orders] ALTER COLUMN [status] bigint NOT NULL", + "ALTER TABLE [dbo].[orders] ADD DEFAULT (1) FOR [status]", + ] ); } #[test] - fn sp_rename_quotes_parts() { - assert_eq!( - MSSQL_DDL.rename_column(&dialect(), &table(), "a.b]c", "d"), - "EXEC sp_rename N'[dbo].[orders].[a.b]]c]', N'd', N'COLUMN'" + fn mssql_default_drop_uses_catalog_name_with_bracket_escaping() { + let cleared = alter(&orders(), |draft| draft.columns[1].default = ColumnDefault::None); + let added = alter(&orders(), |draft| { + draft.columns[2].default = ColumnDefault::Literal(Value::Text("x".to_owned())); + }); + + let cleared = sql(cleared); + assert_eq!(cleared, ["ALTER TABLE [dbo].[orders] DROP CONSTRAINT [DF]]x]"]); + assert!( + cleared + .iter() + .all(|sql| !sql.contains("DECLARE") && !sql.contains("EXEC(")) ); + assert_eq!(sql(added), ["ALTER TABLE [dbo].[orders] ADD DEFAULT (N'x') FOR [note]"]); } #[test] - fn a_default_change_drops_the_named_constraint_then_adds_the_new_default() { - let mut draft = DraftColumn::from_definition(column("status", "int", Some("DF_status"))); - draft.default_value = Some("1".to_owned()); + fn a_dropped_column_loses_its_default_constraint_first() { + let plan = alter(&orders(), |draft| { + draft.columns.retain(|column| column.name != "status") + }); assert_eq!( - alter(&draft), - vec![ - "ALTER TABLE [dbo].[orders] DROP CONSTRAINT [DF_status]".to_owned(), - "ALTER TABLE [dbo].[orders] ADD DEFAULT (1) FOR [status]".to_owned(), + sql(plan), + [ + "ALTER TABLE [dbo].[orders] DROP CONSTRAINT [DF]]x]", + "ALTER TABLE [dbo].[orders] DROP COLUMN [status]", ] ); } #[test] - fn clearing_a_default_only_drops() { - let mut draft = DraftColumn::from_definition(column("status", "int", Some("DF_status"))); - draft.default_value = None; + fn sp_rename_quotes_parts_with_national_literals() { + let plan = alter(&orders(), |draft| { + draft.table.name = "đơn hàng".to_owned(); + draft.columns[2].name = "o'brien".to_owned(); + }); assert_eq!( - alter(&draft), - vec!["ALTER TABLE [dbo].[orders] DROP CONSTRAINT [DF_status]".to_owned()] + sql(plan), + [ + "EXEC sp_rename N'[dbo].[orders]', N'đơn hàng'", + "EXEC sp_rename N'[dbo].[đơn hàng].[note]', N'o''brien', N'COLUMN'", + ] + ); + let dotted = alter(&orders(), |draft| draft.columns[2].name = "d".to_owned()); + let mut table = orders(); + table.columns[2].info.name = "a.b]c".to_owned(); + let bracketed = alter(&table, |draft| draft.columns[2].name = "d".to_owned()); + assert_eq!( + sql(dotted), + ["EXEC sp_rename N'[dbo].[orders].[note]', N'd', N'COLUMN'"] + ); + assert_eq!( + sql(bracketed), + ["EXEC sp_rename N'[dbo].[orders].[a.b]]c]', N'd', N'COLUMN'"] ); } #[test] - fn a_new_default_without_a_constraint_is_only_added() { - let mut draft = DraftColumn::from_definition(column("status", "int", None)); - draft.default_value = Some("0".to_owned()); + fn a_comment_change_picks_the_procedure_for_what_was_there() { + let mut table = orders(); + table.columns[2].info.comment = Some("old".to_owned()); - assert_eq!( - alter(&draft), - vec!["ALTER TABLE [dbo].[orders] ADD DEFAULT (0) FOR [status]".to_owned()] - ); + let updated = alter(&table, |draft| draft.columns[2].comment = Some("new".to_owned())); + let dropped = alter(&table, |draft| draft.columns[2].comment = None); + + assert!(sql(updated)[0].contains("EXEC sp_updateextendedproperty @name = N'MS_Description', @value = N'new'")); + assert!(sql(dropped)[0].contains("EXEC sp_dropextendedproperty @name = N'MS_Description'")); } #[test] - fn the_default_drop_comes_before_a_type_change() { - let mut draft = DraftColumn::from_definition(column("status", "int", Some("DF_status"))); - draft.data_type = "bigint".to_owned(); - draft.default_value = Some("1".to_owned()); + fn a_primary_key_change_drops_and_adds_the_named_constraint() { + let plan = alter(&orders(), |draft| { + draft.columns[2].primary_key = true; + draft.columns[2].nullable = false; + }); assert_eq!( - alter(&draft), - vec![ - "ALTER TABLE [dbo].[orders] DROP CONSTRAINT [DF_status]".to_owned(), - "ALTER TABLE [dbo].[orders] ALTER COLUMN [status] bigint NULL".to_owned(), - "ALTER TABLE [dbo].[orders] ADD DEFAULT (1) FOR [status]".to_owned(), + sql(plan), + [ + "ALTER TABLE [dbo].[orders] DROP CONSTRAINT [PK_orders]", + "ALTER TABLE [dbo].[orders] ALTER COLUMN [note] nvarchar(20) NOT NULL", + "ALTER TABLE [dbo].[orders] ADD CONSTRAINT [PK_orders] PRIMARY KEY ([id], [note])", ] ); } + fn referenced(table: &mut TableDefinition, column: &str) { + table.inbound_foreign_keys = vec![InboundForeignKey { + name: "lines_order_fk".to_owned(), + table: TableRef::new(Some("dbo".to_owned()), "lines"), + columns: vec!["order_id".to_owned()], + referenced_columns: vec![column.to_owned()], + }]; + } + #[test] - fn a_saved_rename_and_default_change_use_sp_rename_and_the_static_drop() { - let mut draft = DraftColumn::from_definition(column("x", "nvarchar(20)", Some("DF_x"))); - draft.default_value = Some("N'x'".to_owned()); - let ops = vec![ - StructureOp::RenameTable { - schema: Some("dbo".to_owned()), - old_name: "old_t".to_owned(), - new_name: "new_t".to_owned(), - }, - StructureOp::AlterColumn { - schema: Some("dbo".to_owned()), - table: "new_t".to_owned(), - column: draft, - }, - ]; + fn primary_key_referenced_by_foreign_key_is_refused() { + let mut table = orders(); + referenced(&mut table, "id"); - let statements = materialize_ops(&ops, "mssql", &dialect()).expect("the change builds"); + let permissions = dialect().column_capabilities(&table, &table.columns[2]); assert_eq!( - statements, - vec![ - "EXEC sp_rename N'[dbo].[old_t]', N'new_t'".to_owned(), - "ALTER TABLE [dbo].[new_t] DROP CONSTRAINT [DF_x]".to_owned(), - "ALTER TABLE [dbo].[new_t] ADD DEFAULT (N'x') FOR [x]".to_owned(), - ] + permissions.alter_primary_key, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec![ + "lines_order_fk".to_owned() + ])) ); + } + + #[test] + fn drop_of_referenced_column_is_refused() { + let mut table = orders(); + referenced(&mut table, "note"); + + let plan = alter(&table, |draft| draft.columns.retain(|column| column.name != "note")); + assert!( - statements - .iter() - .all(|sql| !sql.contains("DECLARE") && !sql.contains("EXEC(")) + matches!( + plan, + Err(BuildDdlError::Refused { + operation: DdlOperation::DropColumn, + reason: RefusalReason::ReferencedByForeignKeys(_), + .. + }) + ), + "{plan:?}" ); } #[test] - fn the_structure_tab_offers_what_sql_server_can_alter() { - let capabilities = MSSQL_DDL.capabilities(); + fn restrict_is_not_an_action_sql_server_takes() { + assert!(!dialect().referential_actions().contains(&ReferentialAction::Restrict)); + assert!(DdlDialect::capabilities(&dialect()).partial_indexes); + assert!(!DdlDialect::capabilities(&dialect()).expression_index_keys); + assert_eq!( + sql(dialect().plan_drop_table( + &TableRef::new(Some("dbo".to_owned()), "orders"), + DropTableOptions { + if_exists: true, + cascade: false + } + )), + ["DROP TABLE IF EXISTS [dbo].[orders]"] + ); + } + + #[test] + fn every_type_suggestion_parses() { + let dialect = dialect(); - assert!(capabilities.alter_column_type && capabilities.alter_column_default); - assert!(!capabilities.alter_auto_increment); - assert!(!capabilities.expression_index_keys); - assert!(capabilities.partial_indexes); + for suggestion in dialect.type_suggestions() { + assert!(dialect.parse_type(suggestion).is_ok(), "{suggestion}"); + } + assert!(dialect.parse_type(dialect.default_column_type()).is_ok()); } } diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index 3a6d729cdc..d821c19358 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -15,7 +15,6 @@ use tablepro_core::statement::{BoundParam, ParamType}; use tablepro_core::value::{Temporal, Value}; use crate::catalog::SqlServerType; -use crate::ddl::MSSQL_DDL; /// A string literal in the national character set. Without the `N`, the /// server reads the text in the database's code page and loses every @@ -210,7 +209,7 @@ impl SqlDialect for MssqlDialect { } fn ddl(&self) -> Option<&dyn DdlDialect> { - Some(&MSSQL_DDL) + Some(self) } /// A type the driver cannot decode is read as the server's text, and diff --git a/linux/crates/drivers/mssql/src/lib.rs b/linux/crates/drivers/mssql/src/lib.rs index ab9d91b3e6..8c59c0497a 100644 --- a/linux/crates/drivers/mssql/src/lib.rs +++ b/linux/crates/drivers/mssql/src/lib.rs @@ -36,7 +36,6 @@ mod transport_io; mod workspace_call; mod write_batch; -pub use ddl::MssqlDdl; pub use dialect::MssqlDialect; pub use driver::MssqlDriver; pub use mssql_manager::WORKSPACE_POOL_MAX_SIZE; diff --git a/linux/crates/drivers/mssql/tests/integration/conformance_engine.rs b/linux/crates/drivers/mssql/tests/integration/conformance_engine.rs index 70a52bc41f..83fc395f37 100644 --- a/linux/crates/drivers/mssql/tests/integration/conformance_engine.rs +++ b/linux/crates/drivers/mssql/tests/integration/conformance_engine.rs @@ -13,8 +13,8 @@ use tablepro_core::{ConnectOptions, DatabaseDriver, LivenessPolicy, NetworkEndpo use tablepro_test_fixtures::MssqlFixture; use tablepro_test_support::FaultTransport; use tablepro_test_support::conformance::{ - BoundedDdlEngine, ConformanceEngine, ConformanceFailure, EditorTransactionEngine, NetworkConformanceEngine, - PooledConformanceEngine, TypedConformanceEngine, UnsupportedTypeCase, + BoundedDdlEngine, ConformanceDdlEngine, ConformanceEngine, ConformanceFailure, EditorTransactionEngine, + NetworkConformanceEngine, PooledConformanceEngine, TypedConformanceEngine, UnsupportedTypeCase, }; use crate::support::{dbo, delay, direct, options_over, password, quoted, require}; @@ -142,6 +142,24 @@ impl BoundedDdlEngine for MssqlEngine { } } +impl ConformanceDdlEngine for MssqlEngine { + fn integer_type(&self) -> &'static str { + "int" + } + + fn text_type(&self) -> &'static str { + "nvarchar(255)" + } + + fn timestamp_type(&self) -> &'static str { + "datetime2" + } + + fn current_timestamp_expression(&self) -> &'static str { + "getdate()" + } +} + mod suite { use super::MssqlEngine; @@ -154,4 +172,9 @@ mod suite { unsupported_types: true, bounded_ddl: true, } + + tablepro_test_support::ddl_conformance_suite! { + engine: MssqlEngine, + docker: true, + } } diff --git a/linux/crates/drivers/mssql/tests/integration/metadata.rs b/linux/crates/drivers/mssql/tests/integration/metadata.rs index 5383f8b78d..eb1d94cc36 100644 --- a/linux/crates/drivers/mssql/tests/integration/metadata.rs +++ b/linux/crates/drivers/mssql/tests/integration/metadata.rs @@ -2,10 +2,9 @@ use drivers_mssql::MssqlDialect; use tablepro_core::column::{ColumnDefault, ColumnKind, ReadForm, TextLength}; -use tablepro_core::ddl::IdentityStyle; +use tablepro_core::ddl::{DdlDialect, IdentityStyle, TableDraft, diff_table}; use tablepro_core::dialect::{BindTarget, LikeForm}; use tablepro_core::meta::{ObjectKind, ObjectScope, RowIdentity, TableRef}; -use tablepro_core::sql_ddl::{DraftColumn, build_alter_column}; use tablepro_core::statement::StatementBuilder; use tablepro_core::{RowLimit, Value}; @@ -234,13 +233,15 @@ async fn alter_default_on_column_with_named_constraint() { .await .expect("the definition"); let dialect = MssqlDialect::new(connection.server_info().major); - let mut draft = DraftColumn::from_definition(definition.column("state").expect("state").clone()); - draft.default_value = Some("1".to_owned()); + let mut draft = TableDraft::from_definition(&definition); + if let Some(state) = draft.columns.iter_mut().find(|column| column.name == "state") { + state.default = ColumnDefault::Literal(Value::Int(1)); + } - let statements = - build_alter_column("mssql", &dialect, Some("dbo"), "jobs", &draft).expect("the default change builds"); - let statements: Vec<&str> = statements.iter().map(String::as_str).collect(); - setup(connection.as_ref(), &statements).await; + let plan = dialect + .plan_alter_table(&definition, &diff_table(&definition, &draft)) + .expect("the default change plans"); + setup(connection.as_ref(), &plan.sql()).await; let state = connection .fetch_table_definition(&table, within(CALL)) diff --git a/linux/crates/drivers/mysql/src/ddl.rs b/linux/crates/drivers/mysql/src/ddl.rs new file mode 100644 index 0000000000..e92502beff --- /dev/null +++ b/linux/crates/drivers/mysql/src/ddl.rs @@ -0,0 +1,656 @@ +use tablepro_core::column::ColumnDefault; +use tablepro_core::ddl::ddl_sql::{ + column_list, create_index_sql, default_sql, foreign_key_sql, index_keys_sql, parenthesized, +}; +use tablepro_core::ddl::{ + BuildDdlError, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlPlan, DraftColumn, Permission, + ReferentialAction, RefusalReason, TableChanges, TableDefinition, TableDraft, +}; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::TableRef; + +use crate::dialect::MySqlDialect; + +const fn capabilities(expression_index_keys: bool) -> DdlCapabilities { + DdlCapabilities { + create_table: true, + rename_table: true, + add_column: true, + drop_column: true, + rename_column: true, + alter_column_type: true, + alter_column_nullability: true, + nullability_switch: true, + alter_column_default: true, + alter_primary_key: true, + alter_auto_increment: true, + create_index: true, + drop_index: true, + expression_index_keys, + partial_indexes: false, + add_foreign_key: true, + drop_foreign_key: true, + column_comments: true, + // MySQL parses CASCADE on DROP TABLE and does nothing with it. + drop_table_cascade: false, + } +} + +static FUNCTIONAL_KEYS: DdlCapabilities = capabilities(true); +static COLUMN_KEYS: DdlCapabilities = capabilities(false); + +/// InnoDB parses SET DEFAULT and then refuses the table that uses it. +const REFERENTIAL_ACTIONS: &[ReferentialAction] = &[ + ReferentialAction::NoAction, + ReferentialAction::Restrict, + ReferentialAction::Cascade, + ReferentialAction::SetNull, +]; + +const TYPE_SUGGESTIONS: &[&str] = &[ + "INT", + "BIGINT", + "SMALLINT", + "TINYINT", + "VARCHAR(255)", + "TEXT", + "BOOLEAN", + "DATETIME", + "TIMESTAMP", + "DATE", + "TIME", + "DECIMAL(10, 2)", + "FLOAT", + "DOUBLE", + "JSON", + "BLOB", + "CHAR(36)", +]; + +/// MySQL restates a column whole, so every changed column is one +/// MODIFY or CHANGE carrying its full definition, and the column changes +/// share one ALTER TABLE. A foreign key is dropped in a statement before +/// it and added in one after it: MySQL refuses to drop and add a key of +/// the same name in one statement. MySQL commits each DDL statement on +/// its own, so a failure leaves the statements before it applied. +impl DdlDialect for MySqlDialect { + fn capabilities(&self) -> &DdlCapabilities { + match self.has_functional_key_parts() { + true => &FUNCTIONAL_KEYS, + false => &COLUMN_KEYS, + } + } + + fn sql_dialect(&self) -> &dyn SqlDialect { + self + } + + /// A MODIFY restates the type, so a column whose type the catalogue + /// did not spell cannot take one. + fn column_capabilities(&self, table: &TableDefinition, column: &ColumnDefinition) -> ColumnCapabilities { + let mut permissions = ColumnCapabilities::standard(DdlDialect::capabilities(self), table, column); + if column.info.column_type.catalog.is_none() { + let unreadable = || Permission::Refused(RefusalReason::UnreadableDefinition); + permissions.alter_nullability = permissions.alter_nullability.and(unreadable); + permissions.alter_default = permissions.alter_default.and(unreadable); + permissions.alter_auto_increment = permissions.alter_auto_increment.and(unreadable); + } + permissions + } + + fn type_suggestions(&self) -> &'static [&'static str] { + TYPE_SUGGESTIONS + } + + fn default_column_type(&self) -> &'static str { + "VARCHAR(255)" + } + + fn referential_actions(&self) -> &'static [ReferentialAction] { + REFERENTIAL_ACTIONS + } + + fn plan_create_table(&self, draft: &TableDraft) -> Result { + draft.check_create(self)?; + let table = TableRef::new(draft.table.schema.clone(), draft.table.name.trim()); + let mut definitions = draft + .columns + .iter() + .map(|column| self.column_definition(column)) + .collect::, _>>()?; + let key: Vec = draft.primary_key().into_iter().map(str::to_owned).collect(); + if !key.is_empty() { + definitions.push(format!("PRIMARY KEY ({})", column_list(self, &key))); + } + definitions.extend(draft.foreign_keys.iter().map(|key| foreign_key_sql(self, &table, key))); + let mut statements = vec![format!( + "CREATE TABLE {} (\n {}\n)", + self.qualify(&table), + definitions.join(",\n ") + )]; + statements.extend( + draft + .indexes + .iter() + .filter(|index| !index.primary) + .map(|index| create_index_sql(self, &table, index)), + ); + Ok(DdlPlan::from_sql(statements)) + } + + fn plan_alter_table(&self, original: &TableDefinition, changes: &TableChanges) -> Result { + changes.check(self, original)?; + let mut statements = Vec::new(); + let table = changes.target(&original.table); + // The new name is qualified too: a bare one resolves against the + // session's default database and would move the table there. + if changes.renamed_table.is_some() { + statements.push(format!( + "RENAME TABLE {} TO {}", + self.qualify(&original.table), + self.qualify(&table) + )); + } + let qualified = self.qualify(&table); + if !changes.dropped_foreign_keys.is_empty() { + let drops: Vec = changes + .dropped_foreign_keys + .iter() + .map(|key| format!("DROP FOREIGN KEY {}", self.quote_identifier(&key.name))) + .collect(); + statements.push(format!("ALTER TABLE {qualified} {}", drops.join(", "))); + } + + let mut actions = Vec::new(); + for index in &changes.dropped_indexes { + actions.push(format!("DROP INDEX {}", self.quote_identifier(&index.name))); + } + if changes.primary_key.as_ref().is_some_and(|change| change.from.is_some()) { + actions.push("DROP PRIMARY KEY".to_owned()); + } + for column in &changes.dropped_columns { + actions.push(format!("DROP COLUMN {}", self.quote_identifier(column.name()))); + } + let restated = |from: &str| { + changes + .altered_columns + .iter() + .find(|alteration| alteration.original.name() == from && restates(alteration.changed)) + }; + // A renamed column that also changed goes through CHANGE, which + // renames and restates at once; one that only changed its name + // keeps every attribute through RENAME COLUMN. + for rename in &changes.renamed_columns { + match restated(&rename.from) { + Some(alteration) => actions.push(format!( + "CHANGE COLUMN {} {}", + self.quote_identifier(&rename.from), + self.column_definition(&alteration.draft)? + )), + None => actions.push(format!( + "RENAME COLUMN {} TO {}", + self.quote_identifier(&rename.from), + self.quote_identifier(&rename.to) + )), + } + } + for alteration in &changes.altered_columns { + let renamed = alteration.original.name() != alteration.draft.name; + if !renamed && restates(alteration.changed) { + actions.push(format!("MODIFY COLUMN {}", self.column_definition(&alteration.draft)?)); + } + } + for column in &changes.added_columns { + actions.push(format!("ADD COLUMN {}", self.column_definition(column)?)); + } + if let Some(change) = changes.primary_key.as_ref().filter(|change| !change.to.is_empty()) { + actions.push(format!("ADD PRIMARY KEY ({})", column_list(self, &change.to))); + } + for index in &changes.added_indexes { + let unique = if index.unique { "UNIQUE " } else { "" }; + actions.push(format!( + "ADD {unique}INDEX {} ({})", + self.quote_identifier(&index.name), + index_keys_sql(self, index) + )); + } + if !actions.is_empty() { + statements.push(format!("ALTER TABLE {qualified} {}", actions.join(", "))); + } + if !changes.added_foreign_keys.is_empty() { + let adds: Vec = changes + .added_foreign_keys + .iter() + .map(|key| format!("ADD {}", foreign_key_sql(self, &table, key))) + .collect(); + statements.push(format!("ALTER TABLE {qualified} {}", adds.join(", "))); + } + Ok(DdlPlan::from_sql(statements)) + } + + /// The server's own SHOW CREATE TABLE, which carries the engine, + /// charset and every option the definition does not model. + fn create_table_script(&self, definition: &TableDefinition) -> Result { + match &definition.source { + Some(source) => Ok(DdlPlan::from_sql([source.create_statement.clone()])), + None => self.plan_create_table(&TableDraft::from_definition(definition)), + } + } +} + +/// Whether an alteration needs the column's definition restated. A +/// primary-key flag alone is the table's key clause. +fn restates(changed: tablepro_core::ddl::ChangedAttributes) -> bool { + changed.column_type || changed.nullability || changed.default || changed.auto_increment || changed.comment +} + +impl MySqlDialect { + /// `name type NULL|NOT NULL [DEFAULT ..] [AUTO_INCREMENT] [COMMENT ..]`, + /// the whole definition MODIFY replaces the old one with. + fn column_definition(&self, column: &DraftColumn) -> Result { + let resolved = column.column_type.resolve(self, &column.name)?; + let mut parts = vec![self.quote_identifier(&column.name), resolved.sql.clone()]; + parts.push(if column.nullable { "NULL" } else { "NOT NULL" }.to_owned()); + if !column.auto_increment + && let Some(default) = default_sql(self, &column.name, &column.default, &resolved.column_type)? + { + parts.push(format!( + "DEFAULT {}", + default_clause(&column.default, &default, &resolved.sql) + )); + } + if column.auto_increment { + parts.push("AUTO_INCREMENT".to_owned()); + } + if let Some(comment) = column.comment_text() { + parts.push(format!("COMMENT {}", comment_literal(comment))); + } + Ok(parts.join(" ")) + } +} + +/// MySQL takes an expression default only in parentheses, apart from the +/// CURRENT_TIMESTAMP family, and takes a literal on a BLOB, TEXT, JSON or +/// spatial column only as an expression. +fn default_clause(default: &ColumnDefault, sql: &str, declaration: &str) -> String { + match default { + ColumnDefault::Expression(_) if is_current_timestamp(sql) => sql.to_owned(), + ColumnDefault::Expression(_) => parenthesized(sql), + ColumnDefault::Literal(_) if needs_expression_default(declaration) => format!("({sql})"), + _ => sql.to_owned(), + } +} + +fn is_current_timestamp(sql: &str) -> bool { + let compact: String = sql + .chars() + .filter(|character| !character.is_whitespace()) + .collect::() + .to_ascii_lowercase(); + let name = compact.split('(').next().unwrap_or_default(); + let arguments = compact.get(name.len()..).unwrap_or_default(); + let precision_only = arguments.is_empty() + || (arguments.starts_with('(') + && arguments.ends_with(')') + && arguments + .get(1..arguments.len() - 1) + .is_some_and(|digits| digits.chars().all(|character| character.is_ascii_digit()))); + matches!(name, "current_timestamp" | "now" | "localtime" | "localtimestamp") && precision_only +} + +fn needs_expression_default(declaration: &str) -> bool { + let name = declaration + .trim() + .split(|character: char| character == '(' || character.is_whitespace()) + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + matches!( + name.as_str(), + "tinytext" + | "text" + | "mediumtext" + | "longtext" + | "tinyblob" + | "blob" + | "mediumblob" + | "longblob" + | "json" + | "geometry" + | "point" + | "linestring" + | "polygon" + | "multipoint" + | "multilinestring" + | "multipolygon" + | "geometrycollection" + ) +} + +/// COMMENT takes a plain string only, with no introducer or hex form. +/// Workspace sessions run with backslash escapes on, so doubling both the +/// quote and the backslash reads back as the text. +fn comment_literal(text: &str) -> String { + format!("'{}'", text.replace('\\', "\\\\").replace('\'', "''")) +} + +#[cfg(test)] +mod tests { + use tablepro_core::ForeignKeyInfo; + use tablepro_core::ServerInfo; + use tablepro_core::column::{ColumnInfo, SqlExpression}; + use tablepro_core::ddl::{ + ColumnAttributes, DdlOperation, DraftType, DropTableOptions, InboundForeignKey, PrimaryKeyConstraint, + diff_table, + }; + use tablepro_core::meta::{IndexInfo, IndexKey}; + use tablepro_core::value::Value; + + use super::*; + use crate::flavour::MySqlFlavour; + + fn dialect() -> MySqlDialect { + MySqlDialect::new(MySqlFlavour::MySql, &ServerInfo::new("MySQL", "8.4.3", 8, 4, 3)) + } + + fn column(name: &str, declaration: &str, primary_key: bool) -> ColumnDefinition { + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type: dialect().parse_type(declaration).expect("a MySQL type"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) + } + + fn users() -> TableDefinition { + TableDefinition { + table: TableRef::new(Some("app".to_owned()), "users"), + columns: vec![ + column("id", "int", true), + column("email", "varchar(255)", false), + column("account_id", "int", false), + ], + primary_key: Some(PrimaryKeyConstraint { + name: Some("PRIMARY".to_owned()), + columns: vec!["id".to_owned()], + }), + indexes: Vec::new(), + foreign_keys: vec![foreign_key("users_account_fk")], + inbound_foreign_keys: Vec::new(), + source: None, + } + } + + fn foreign_key(name: &str) -> ForeignKeyInfo { + ForeignKeyInfo { + name: name.to_owned(), + columns: vec!["account_id".to_owned()], + ref_schema: Some("app".to_owned()), + ref_table: "accounts".to_owned(), + ref_columns: vec!["id".to_owned()], + on_delete: Some(ReferentialAction::Cascade), + on_update: None, + } + } + + fn alter(original: &TableDefinition, edit: impl FnOnce(&mut TableDraft)) -> Result { + let mut draft = TableDraft::from_definition(original); + edit(&mut draft); + dialect().plan_alter_table(original, &diff_table(original, &draft)) + } + + fn sql(plan: Result) -> Vec { + plan.expect("the change plans") + .sql() + .into_iter() + .map(str::to_owned) + .collect() + } + + fn typed(name: &str, declaration: &str) -> DraftColumn { + DraftColumn::new(name, DraftType::Typed(declaration.to_owned())) + } + + #[test] + fn plan_create_table_simple() { + let mut draft = TableDraft::new(TableRef::new(Some("app".to_owned()), "notes")); + let mut id = typed("id", "BIGINT"); + id.primary_key = true; + id.nullable = false; + id.auto_increment = true; + let mut body = typed("body", "TEXT"); + body.default = ColumnDefault::Literal(Value::Text("it's".to_owned())); + body.comment = Some("a \\ note".to_owned()); + let mut created = typed("created_at", "TIMESTAMP"); + created.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("CURRENT_TIMESTAMP")); + let mut token = typed("token", "CHAR(36)"); + token.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("uuid()")); + draft.columns = vec![id, body, created, token]; + + assert_eq!( + sql(dialect().plan_create_table(&draft)), + [ + "CREATE TABLE `app`.`notes` (\n `id` BIGINT NOT NULL AUTO_INCREMENT,\n `body` TEXT NULL DEFAULT \ + ('it''s') COMMENT 'a \\\\ note',\n `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,\n `token` \ + CHAR(36) NULL DEFAULT (uuid()),\n PRIMARY KEY (`id`)\n)" + ] + ); + } + + #[test] + fn plan_create_table_composite_key_index_and_foreign_key() { + let mut draft = TableDraft::new(TableRef::unqualified("lines")); + let mut order = typed("order_id", "INT"); + order.primary_key = true; + order.nullable = false; + let mut line = typed("line", "INT"); + line.primary_key = true; + line.nullable = false; + draft.columns = vec![order, line, typed("sku", "VARCHAR(40)")]; + draft.indexes = vec![IndexInfo { + name: "by_sku".to_owned(), + keys: vec![IndexKey::Column { + name: "sku".to_owned(), + descending: false, + }], + unique: false, + primary: false, + predicate: None, + }]; + draft.foreign_keys = vec![ForeignKeyInfo { + ref_schema: None, + columns: vec!["order_id".to_owned()], + ..foreign_key("lines_order_fk") + }]; + + assert_eq!( + sql(dialect().plan_create_table(&draft)), + [ + "CREATE TABLE `lines` (\n `order_id` INT NOT NULL,\n `line` INT NOT NULL,\n `sku` VARCHAR(40) \ + NULL,\n PRIMARY KEY (`order_id`, `line`),\n CONSTRAINT `lines_order_fk` FOREIGN KEY (`order_id`) \ + REFERENCES `accounts` (`id`) ON DELETE CASCADE\n)", + "CREATE INDEX `by_sku` ON `lines` (`sku`)", + ] + ); + } + + #[test] + fn plan_alter_table_nullability_type_and_default() { + let plan = alter(&users(), |draft| { + let email = &mut draft.columns[1]; + email.column_type = DraftType::Typed("varchar(320)".to_owned()); + email.nullable = false; + email.default = ColumnDefault::Literal(Value::Text(String::new())); + }); + + assert_eq!( + sql(plan), + ["ALTER TABLE `app`.`users` MODIFY COLUMN `email` varchar(320) NOT NULL DEFAULT ''"] + ); + } + + #[test] + fn mysql_drop_fk_precedes_combined_alter() { + let plan = alter(&users(), |draft| { + draft.foreign_keys = vec![ForeignKeyInfo { + on_delete: Some(ReferentialAction::SetNull), + ..foreign_key("users_account_fk") + }]; + draft.columns[2].nullable = false; + draft.columns.push(typed("nickname", "varchar(40)")); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE `app`.`users` DROP FOREIGN KEY `users_account_fk`", + "ALTER TABLE `app`.`users` MODIFY COLUMN `account_id` int NOT NULL, ADD COLUMN `nickname` \ + varchar(40) NULL", + "ALTER TABLE `app`.`users` ADD CONSTRAINT `users_account_fk` FOREIGN KEY (`account_id`) REFERENCES \ + `app`.`accounts` (`id`) ON DELETE SET NULL", + ] + ); + } + + #[test] + fn mysql_rename_keeps_the_table_in_its_database() { + let plan = alter(&users(), |draft| draft.table.name = "members".to_owned()); + + assert_eq!(sql(plan), ["RENAME TABLE `app`.`users` TO `app`.`members`"]); + } + + #[test] + fn a_rename_alone_keeps_the_definition_and_one_with_changes_restates_it() { + let only_renamed = alter(&users(), |draft| draft.columns[1].name = "mail".to_owned()); + let renamed_and_changed = alter(&users(), |draft| { + draft.columns[1].name = "mail".to_owned(); + draft.columns[1].nullable = false; + }); + + assert_eq!( + sql(only_renamed), + ["ALTER TABLE `app`.`users` RENAME COLUMN `email` TO `mail`"] + ); + assert_eq!( + sql(renamed_and_changed), + ["ALTER TABLE `app`.`users` CHANGE COLUMN `email` `mail` varchar(255) NOT NULL"] + ); + } + + #[test] + fn a_primary_key_change_is_one_statement() { + let plan = alter(&users(), |draft| { + draft.columns[1].primary_key = true; + draft.columns[1].nullable = false; + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE `app`.`users` DROP PRIMARY KEY, MODIFY COLUMN `email` varchar(255) NOT NULL, ADD PRIMARY \ + KEY (`id`, `email`)" + ] + ); + } + + fn referenced(table: &mut TableDefinition, column: &str) { + table.inbound_foreign_keys = vec![InboundForeignKey { + name: "orders_user_fk".to_owned(), + table: TableRef::new(Some("app".to_owned()), "orders"), + columns: vec!["user_id".to_owned()], + referenced_columns: vec![column.to_owned()], + }]; + } + + #[test] + fn primary_key_referenced_by_foreign_key_is_refused() { + let mut table = users(); + referenced(&mut table, "id"); + + let permissions = dialect().column_capabilities(&table, &table.columns[1]); + + assert_eq!( + permissions.alter_primary_key, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec![ + "orders_user_fk".to_owned() + ])) + ); + } + + #[test] + fn drop_of_referenced_column_is_refused() { + let mut table = users(); + referenced(&mut table, "email"); + + let plan = alter(&table, |draft| draft.columns.retain(|column| column.name != "email")); + + assert!( + matches!( + plan, + Err(BuildDdlError::Refused { + operation: DdlOperation::DropColumn, + reason: RefusalReason::ReferencedByForeignKeys(_), + .. + }) + ), + "{plan:?}" + ); + } + + #[test] + fn set_default_is_not_offered_and_is_refused() { + let plan = alter(&users(), |draft| { + draft.foreign_keys[0].on_delete = Some(ReferentialAction::SetDefault); + }); + + assert!(!dialect().referential_actions().contains(&ReferentialAction::SetDefault)); + assert_eq!( + plan, + Err(BuildDdlError::ReferentialAction(ReferentialAction::SetDefault)) + ); + } + + #[test] + fn the_script_is_the_servers_own_text() { + let mut table = users(); + table.source = Some(tablepro_core::ddl::TableSource { + create_statement: "CREATE TABLE `users` (\n `id` int NOT NULL\n) ENGINE=InnoDB".to_owned(), + dependents: Vec::new(), + }); + + assert_eq!( + sql(dialect().create_table_script(&table)), + ["CREATE TABLE `users` (\n `id` int NOT NULL\n) ENGINE=InnoDB"] + ); + assert_eq!( + sql(dialect().plan_drop_table(&table.table, DropTableOptions::default())), + ["DROP TABLE `app`.`users`"] + ); + } + + #[test] + fn only_the_current_timestamp_family_goes_bare() { + assert!(is_current_timestamp("CURRENT_TIMESTAMP")); + assert!(is_current_timestamp("current_timestamp(6)")); + assert!(is_current_timestamp("now()")); + assert!(!is_current_timestamp("now() + interval 1 day")); + assert!(!is_current_timestamp("uuid()")); + } + + #[test] + fn every_type_suggestion_parses() { + let dialect = dialect(); + + for suggestion in dialect.type_suggestions() { + assert!(dialect.parse_type(suggestion).is_ok(), "{suggestion}"); + } + assert!(dialect.parse_type(dialect.default_column_type()).is_ok()); + } +} diff --git a/linux/crates/drivers/mysql/src/dialect.rs b/linux/crates/drivers/mysql/src/dialect.rs index 5c87a18a04..fb3a725043 100644 --- a/linux/crates/drivers/mysql/src/dialect.rs +++ b/linux/crates/drivers/mysql/src/dialect.rs @@ -3,7 +3,7 @@ use tablepro_core::column::{ BoolStorage, ColumnInfo, ColumnKind, ColumnType, FloatWidth, IntegerKind, IntegerWidth, SqlExpression, TemporalZone, TextLength, }; -use tablepro_core::ddl::{DdlCapabilities, DdlDialect}; +use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ BindError, BindTarget, DialectCapabilities, KeysetDirection, LikeCase, LikeForm, LiteralError, Placeholder, SqlDialect, @@ -59,48 +59,6 @@ impl MySqlDialect { } } -const fn ddl_capabilities(expression_index_keys: bool) -> DdlCapabilities { - DdlCapabilities { - create_table: true, - rename_table: true, - add_column: true, - drop_column: true, - rename_column: true, - alter_column_type: true, - alter_column_nullability: true, - nullability_switch: true, - alter_column_default: true, - alter_primary_key: true, - alter_auto_increment: true, - create_index: true, - drop_index: true, - expression_index_keys, - partial_indexes: false, - add_foreign_key: true, - drop_foreign_key: true, - // MySQL parses CASCADE on DROP TABLE and does nothing with it. - drop_table_cascade: false, - } -} - -static FUNCTIONAL_KEY_DDL: MySqlDdl = MySqlDdl { - capabilities: ddl_capabilities(true), -}; -static COLUMN_KEY_DDL: MySqlDdl = MySqlDdl { - capabilities: ddl_capabilities(false), -}; - -#[derive(Debug)] -struct MySqlDdl { - capabilities: DdlCapabilities, -} - -impl DdlDialect for MySqlDdl { - fn capabilities(&self) -> &DdlCapabilities { - &self.capabilities - } -} - impl SqlDialect for MySqlDialect { fn capabilities(&self) -> DialectCapabilities { // CLIENT_FOUND_ROWS is on, so an UPDATE reports the rows it @@ -224,10 +182,7 @@ impl SqlDialect for MySqlDialect { } fn ddl(&self) -> Option<&dyn DdlDialect> { - Some(match self.has_functional_key_parts() { - true => &FUNCTIONAL_KEY_DDL, - false => &COLUMN_KEY_DDL, - }) + Some(self) } fn insert_default_row(&self, table_sql: &str) -> Option { @@ -421,20 +376,6 @@ mod tests { MySqlDialect::new(flavour, &ServerInfo::new(flavour.product(), "8.4.3", 8, 4, 3)) } - #[test] - fn renames_quote_with_backticks() { - let dialect = dialect(MySqlFlavour::MySql); - - assert_eq!( - tablepro_core::sql_ddl::build_rename_table("mysql", &dialect, None, "old", "new`t").unwrap(), - "ALTER TABLE `old` RENAME TO `new``t`" - ); - assert_eq!( - tablepro_core::sql_ddl::build_rename_column("mysql", &dialect, None, "t", "old", "new").unwrap(), - "ALTER TABLE `t` RENAME COLUMN `old` TO `new`" - ); - } - #[test] fn an_sql_export_quotes_with_backticks() { let columns = [tablepro_core::column::ResultColumn::new( diff --git a/linux/crates/drivers/mysql/src/lib.rs b/linux/crates/drivers/mysql/src/lib.rs index e9602f73e6..b047be4799 100644 --- a/linux/crates/drivers/mysql/src/lib.rs +++ b/linux/crates/drivers/mysql/src/lib.rs @@ -10,6 +10,7 @@ mod cancel; mod capabilities; mod catalog; mod connection; +mod ddl; mod decode; mod dialect; mod driver; diff --git a/linux/crates/drivers/mysql/tests/integration/conformance_engine.rs b/linux/crates/drivers/mysql/tests/integration/conformance_engine.rs index 2f2adc900a..f61debfdbd 100644 --- a/linux/crates/drivers/mysql/tests/integration/conformance_engine.rs +++ b/linux/crates/drivers/mysql/tests/integration/conformance_engine.rs @@ -12,8 +12,8 @@ use tablepro_core::{ use tablepro_test_fixtures::{MysqlFixture, MysqlFlavour, MysqlOptions}; use tablepro_test_support::FaultTransport; use tablepro_test_support::conformance::{ - BoundedDdlEngine, ConformanceEngine, ConformanceFailure, EditorTransactionEngine, NetworkConformanceEngine, - PooledConformanceEngine, TypedConformanceEngine, UnsupportedTypeCase, + BoundedDdlEngine, ConformanceDdlEngine, ConformanceEngine, ConformanceFailure, EditorTransactionEngine, + NetworkConformanceEngine, PooledConformanceEngine, TypedConformanceEngine, UnsupportedTypeCase, }; use crate::support::{direct, options_over, password}; @@ -179,6 +179,24 @@ impl TypedConformanceEngine for MySqlEngine { } } +impl ConformanceDdlEngine for MySqlEngine { + fn integer_type(&self) -> &'static str { + "int" + } + + fn text_type(&self) -> &'static str { + "varchar(255)" + } + + fn timestamp_type(&self) -> &'static str { + "datetime" + } + + fn current_timestamp_expression(&self) -> &'static str { + "CURRENT_TIMESTAMP" + } +} + /// MariaDB's `max_statement_time` stops a CREATE ... SELECT as it stops a /// SELECT. MySQL's `max_execution_time` bounds only read-only SELECTs, so /// it has no bounded DDL to lift and runs without this case. @@ -204,6 +222,11 @@ mod mysql_suite { unsupported_types: true, bounded_ddl: false, } + + tablepro_test_support::ddl_conformance_suite! { + engine: MySqlEngine, + docker: true, + } } mod mariadb_suite { @@ -218,4 +241,9 @@ mod mariadb_suite { unsupported_types: true, bounded_ddl: true, } + + tablepro_test_support::ddl_conformance_suite! { + engine: MySqlEngine, + docker: true, + } } diff --git a/linux/crates/drivers/postgres/src/ddl.rs b/linux/crates/drivers/postgres/src/ddl.rs new file mode 100644 index 0000000000..684d9672b8 --- /dev/null +++ b/linux/crates/drivers/postgres/src/ddl.rs @@ -0,0 +1,656 @@ +use tablepro_core::column::ColumnType; +use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, default_sql, foreign_key_sql}; +use tablepro_core::ddl::{ + BuildDdlError, CollationName, DdlCapabilities, DdlDialect, DdlPlan, DraftColumn, GeneratedStorage, IdentityStyle, + TableChanges, TableDefinition, TableDraft, +}; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::TableRef; +use tablepro_core::value::Value; + +use crate::dialect::PgDialect; + +static CAPABILITIES: DdlCapabilities = DdlCapabilities { + create_table: true, + rename_table: true, + add_column: true, + drop_column: true, + rename_column: true, + alter_column_type: true, + alter_column_nullability: true, + nullability_switch: true, + alter_column_default: true, + alter_primary_key: true, + // An identity added to a column with rows has to start past them, + // which needs a setval this plan does not write yet. + alter_auto_increment: false, + create_index: true, + drop_index: true, + expression_index_keys: true, + partial_indexes: true, + add_foreign_key: true, + drop_foreign_key: true, + column_comments: true, + drop_table_cascade: true, +}; + +const TYPE_SUGGESTIONS: &[&str] = &[ + "integer", + "bigint", + "smallint", + "text", + "varchar(255)", + "boolean", + "timestamp", + "timestamp with time zone", + "date", + "time", + "numeric(10, 2)", + "real", + "double precision", + "uuid", + "jsonb", + "json", + "bytea", +]; + +/// PostgreSQL changes a table in one ALTER TABLE: it orders the +/// subcommands itself, drops before adds, so one statement carries +/// every column change and the whole change commits or none of it does. +/// Renames cannot share it, and indexes and comments are statements of +/// their own. +impl DdlDialect for PgDialect { + fn capabilities(&self) -> &DdlCapabilities { + &CAPABILITIES + } + + fn sql_dialect(&self) -> &dyn SqlDialect { + self + } + + fn type_suggestions(&self) -> &'static [&'static str] { + TYPE_SUGGESTIONS + } + + fn default_column_type(&self) -> &'static str { + "text" + } + + fn plan_create_table(&self, draft: &TableDraft) -> Result { + draft.check_create(self)?; + let table = TableRef::new(draft.table.schema.clone(), draft.table.name.trim()); + let mut definitions = draft + .columns + .iter() + .map(|column| self.column_definition(column)) + .collect::, _>>()?; + let key: Vec = draft.primary_key().into_iter().map(str::to_owned).collect(); + if !key.is_empty() { + definitions.push(format!("PRIMARY KEY ({})", column_list(self, &key))); + } + definitions.extend(draft.foreign_keys.iter().map(|key| foreign_key_sql(self, &table, key))); + let mut statements = vec![format!( + "CREATE TABLE {} (\n {}\n)", + self.qualify(&table), + definitions.join(",\n ") + )]; + for column in &draft.columns { + if let Some(comment) = column.comment_text() { + statements.push(self.comment_on(&table, &column.name, Some(comment))); + } + } + statements.extend( + draft + .indexes + .iter() + .filter(|index| !index.primary) + .map(|index| create_index_sql(self, &table, index)), + ); + Ok(DdlPlan::from_sql(statements)) + } + + fn plan_alter_table(&self, original: &TableDefinition, changes: &TableChanges) -> Result { + changes.check(self, original)?; + let mut statements = Vec::new(); + if let Some(name) = &changes.renamed_table { + statements.push(format!( + "ALTER TABLE {} RENAME TO {}", + self.qualify(&original.table), + self.quote_identifier(name) + )); + } + let table = changes.target(&original.table); + let qualified = self.qualify(&table); + for rename in &changes.renamed_columns { + statements.push(format!( + "ALTER TABLE {qualified} RENAME COLUMN {} TO {}", + self.quote_identifier(&rename.from), + self.quote_identifier(&rename.to) + )); + } + // A column drop takes its indexes with it, so the explicit drops + // go first. + for index in &changes.dropped_indexes { + let index = TableRef::new(table.schema.clone(), index.name.clone()); + statements.push(format!("DROP INDEX {}", self.qualify(&index))); + } + + let mut actions = Vec::new(); + for key in &changes.dropped_foreign_keys { + actions.push(format!("DROP CONSTRAINT {}", self.quote_identifier(&key.name))); + } + let old_key = changes + .primary_key + .as_ref() + .and_then(|change| change.from.as_ref()) + .and_then(|key| key.name.as_ref()); + if let Some(name) = old_key { + actions.push(format!("DROP CONSTRAINT {}", self.quote_identifier(name))); + } + for column in &changes.dropped_columns { + actions.push(format!("DROP COLUMN {}", self.quote_identifier(column.name()))); + } + for alteration in &changes.altered_columns { + let draft = &alteration.draft; + let name = self.quote_identifier(&draft.name); + let changed = alteration.changed; + if changed.column_type { + let resolved = draft.column_type.resolve(self, &draft.name)?; + actions.push(format!( + "ALTER COLUMN {name} TYPE {} USING {name}::{}", + resolved.sql, resolved.sql + )); + } + if changed.nullability { + let action = if draft.nullable { + "DROP NOT NULL" + } else { + "SET NOT NULL" + }; + actions.push(format!("ALTER COLUMN {name} {action}")); + } + if changed.default { + let column_type = draft.column_type.column_type(self, &draft.name)?; + actions.push(match default_sql(self, &draft.name, &draft.default, &column_type)? { + Some(default) => format!("ALTER COLUMN {name} SET DEFAULT {default}"), + None => format!("ALTER COLUMN {name} DROP DEFAULT"), + }); + } + } + for column in &changes.added_columns { + actions.push(format!("ADD COLUMN {}", self.column_definition(column)?)); + } + if let Some(change) = changes.primary_key.as_ref().filter(|change| !change.to.is_empty()) { + actions.push(format!("ADD PRIMARY KEY ({})", column_list(self, &change.to))); + } + for key in &changes.added_foreign_keys { + actions.push(format!("ADD {}", foreign_key_sql(self, &table, key))); + } + if !actions.is_empty() { + statements.push(format!("ALTER TABLE {qualified} {}", actions.join(", "))); + } + + let altered_comments = changes + .altered_columns + .iter() + .filter(|alteration| alteration.changed.comment) + .map(|alteration| &alteration.draft); + let added_comments = changes + .added_columns + .iter() + .filter(|column| column.comment_text().is_some()); + for column in altered_comments.chain(added_comments) { + statements.push(self.comment_on(&table, &column.name, column.comment_text())); + } + statements.extend( + changes + .added_indexes + .iter() + .map(|index| create_index_sql(self, &table, index)), + ); + Ok(DdlPlan::from_sql(statements)) + } +} + +impl PgDialect { + /// A column as CREATE TABLE and ADD COLUMN declare it. A column read + /// from the catalogue keeps its collation, its identity style and its + /// generation expression. + fn column_definition(&self, column: &DraftColumn) -> Result { + let resolved = column.column_type.resolve(self, &column.name)?; + let attributes = column.original.as_ref().map(|original| &original.attributes); + let mut parts = vec![self.quote_identifier(&column.name), resolved.sql.clone()]; + if let Some(collation) = attributes.and_then(|attributes| attributes.collation.as_ref()) { + parts.push(format!("COLLATE {}", self.collation(collation))); + } + if let Some(generated) = attributes.and_then(|attributes| attributes.generated.as_ref()) { + let storage = match generated.storage { + GeneratedStorage::Stored => "STORED", + GeneratedStorage::Virtual => "VIRTUAL", + }; + parts.push(format!("GENERATED ALWAYS AS ({}) {storage}", generated.expression)); + } else if column.auto_increment { + let always = attributes.is_some_and(|attributes| attributes.identity == IdentityStyle::GeneratedAlways); + parts.push(match always { + true => "GENERATED ALWAYS AS IDENTITY".to_owned(), + false => "GENERATED BY DEFAULT AS IDENTITY".to_owned(), + }); + } else if let Some(default) = default_sql(self, &column.name, &column.default, &resolved.column_type)? { + parts.push(format!("DEFAULT {default}")); + } + if !column.nullable { + parts.push("NOT NULL".to_owned()); + } + Ok(parts.join(" ")) + } + + fn collation(&self, collation: &CollationName) -> String { + match &collation.schema { + Some(schema) => format!( + "{}.{}", + self.quote_identifier(schema), + self.quote_identifier(&collation.name) + ), + None => self.quote_identifier(&collation.name), + } + } + + fn comment_on(&self, table: &TableRef, column: &str, comment: Option<&str>) -> String { + let target = format!("{}.{}", self.qualify(table), self.quote_identifier(column)); + let text = comment.map(|text| Value::Text(text.to_owned())); + match text.and_then(|text| SqlDialect::literal(self, &text, &ColumnType::untyped_text("text")).ok()) { + Some(literal) => format!("COMMENT ON COLUMN {target} IS {literal}"), + None => format!("COMMENT ON COLUMN {target} IS NULL"), + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::ForeignKeyInfo; + use tablepro_core::ServerInfo; + use tablepro_core::column::{ColumnDefault, ColumnInfo, SqlExpression}; + use tablepro_core::ddl::{ + ColumnAttributes, ColumnDefinition, DdlOperation, DraftType, DropTableOptions, InboundForeignKey, Permission, + PrimaryKeyConstraint, ReferentialAction, RefusalReason, diff_table, + }; + use tablepro_core::meta::{IndexInfo, IndexKey}; + + use super::*; + + fn dialect() -> PgDialect { + PgDialect::new(&ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)) + } + + fn column(name: &str, declaration: &str, primary_key: bool) -> ColumnDefinition { + let parsed = dialect() + .parse_type(declaration) + .expect("a type the test dialect parses"); + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type: parsed, + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) + } + + fn users() -> TableDefinition { + TableDefinition { + table: TableRef::new(Some("app".to_owned()), "users"), + columns: vec![ + column("id", "integer", true), + column("email", "text", false), + column("status", "integer", false), + ], + primary_key: Some(PrimaryKeyConstraint { + name: Some("users_pkey".to_owned()), + columns: vec!["id".to_owned()], + }), + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } + } + + fn typed(name: &str, declaration: &str) -> DraftColumn { + DraftColumn::new(name, DraftType::Typed(declaration.to_owned())) + } + + fn alter(original: &TableDefinition, edit: impl FnOnce(&mut TableDraft)) -> Result { + let mut draft = TableDraft::from_definition(original); + edit(&mut draft); + dialect().plan_alter_table(original, &diff_table(original, &draft)) + } + + fn sql(plan: Result) -> Vec { + plan.expect("the change plans") + .sql() + .into_iter() + .map(str::to_owned) + .collect() + } + + #[test] + fn plan_create_table_simple() { + let mut draft = TableDraft::new(TableRef::new(Some("app".to_owned()), "notes")); + let mut id = typed("id", "bigint"); + id.primary_key = true; + id.nullable = false; + id.auto_increment = true; + let mut body = typed("body", "text"); + body.nullable = false; + body.default = ColumnDefault::Literal(Value::Text("it's".to_owned())); + body.comment = Some("the note".to_owned()); + let mut created = typed("created_at", "timestamp with time zone"); + created.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("now()")); + draft.columns = vec![id, body, created]; + + assert_eq!( + sql(dialect().plan_create_table(&draft)), + [ + "CREATE TABLE \"app\".\"notes\" (\n \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,\n \ + \"body\" text DEFAULT 'it''s' NOT NULL,\n \"created_at\" timestamp with time zone DEFAULT now(),\n \ + PRIMARY KEY (\"id\")\n)", + "COMMENT ON COLUMN \"app\".\"notes\".\"body\" IS 'the note'", + ] + ); + } + + #[test] + fn plan_create_table_composite_key_index_and_foreign_key() { + let mut draft = TableDraft::new(TableRef::unqualified("lines")); + let mut order = typed("order_id", "integer"); + order.primary_key = true; + order.nullable = false; + let mut line = typed("line", "integer"); + line.primary_key = true; + line.nullable = false; + draft.columns = vec![order, line, typed("sku", "text")]; + draft.indexes = vec![IndexInfo { + name: "lines_by_sku".to_owned(), + keys: vec![IndexKey::Column { + name: "sku".to_owned(), + descending: true, + }], + unique: true, + primary: false, + predicate: None, + }]; + draft.foreign_keys = vec![ForeignKeyInfo { + name: "lines_order_fk".to_owned(), + columns: vec!["order_id".to_owned()], + ref_schema: None, + ref_table: "orders".to_owned(), + ref_columns: vec!["id".to_owned()], + on_delete: Some(ReferentialAction::Cascade), + on_update: Some(ReferentialAction::NoAction), + }]; + + assert_eq!( + sql(dialect().plan_create_table(&draft)), + [ + "CREATE TABLE \"lines\" (\n \"order_id\" integer NOT NULL,\n \"line\" integer NOT NULL,\n \ + \"sku\" text,\n PRIMARY KEY (\"order_id\", \"line\"),\n CONSTRAINT \"lines_order_fk\" FOREIGN KEY \ + (\"order_id\") REFERENCES \"orders\" (\"id\") ON DELETE CASCADE\n)", + "CREATE UNIQUE INDEX \"lines_by_sku\" ON \"lines\" (\"sku\" DESC)", + ] + ); + } + + #[test] + fn plan_alter_table_nullability_type_and_default_in_one_statement() { + let plan = alter(&users(), |draft| { + let status = &mut draft.columns[2]; + status.column_type = DraftType::Typed("bigint".to_owned()); + status.nullable = false; + status.default = ColumnDefault::Literal(Value::Int(0)); + draft.columns[1].default = ColumnDefault::Expression(SqlExpression::from_catalog_text("'x'")); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE \"app\".\"users\" ALTER COLUMN \"email\" SET DEFAULT 'x', ALTER COLUMN \"status\" TYPE \ + bigint USING \"status\"::bigint, ALTER COLUMN \"status\" SET NOT NULL, ALTER COLUMN \"status\" SET \ + DEFAULT 0" + ] + ); + } + + #[test] + fn renames_run_first_and_the_rest_names_the_new_table() { + let plan = alter(&users(), |draft| { + draft.table.name = "members".to_owned(); + draft.columns[1].name = "mail".to_owned(); + draft.columns[1].nullable = false; + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE \"app\".\"users\" RENAME TO \"members\"", + "ALTER TABLE \"app\".\"members\" RENAME COLUMN \"email\" TO \"mail\"", + "ALTER TABLE \"app\".\"members\" ALTER COLUMN \"mail\" SET NOT NULL", + ] + ); + } + + #[test] + fn a_cleared_default_and_comment_are_dropped() { + let mut original = users(); + original.columns[2].info.default = ColumnDefault::Literal(Value::Int(1)); + original.columns[2].info.comment = Some("state".to_owned()); + + let plan = alter(&original, |draft| { + draft.columns[2].default = ColumnDefault::None; + draft.columns[2].comment = None; + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE \"app\".\"users\" ALTER COLUMN \"status\" DROP DEFAULT", + "COMMENT ON COLUMN \"app\".\"users\".\"status\" IS NULL", + ] + ); + } + + #[test] + fn a_primary_key_change_drops_the_named_constraint_and_adds_the_key() { + let plan = alter(&users(), |draft| { + draft.columns[1].primary_key = true; + draft.columns[1].nullable = false; + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE \"app\".\"users\" DROP CONSTRAINT \"users_pkey\", ALTER COLUMN \"email\" SET NOT NULL, \ + ADD PRIMARY KEY (\"id\", \"email\")" + ] + ); + } + + #[test] + fn dropped_indexes_go_before_the_combined_alter_and_new_ones_after() { + let mut original = users(); + original.indexes = vec![IndexInfo { + name: "by_email".to_owned(), + keys: vec![IndexKey::Column { + name: "email".to_owned(), + descending: false, + }], + unique: false, + primary: false, + predicate: None, + }]; + + let plan = alter(&original, |draft| { + draft.indexes.clear(); + draft.columns.retain(|column| column.name != "email"); + draft.columns.push(typed("nickname", "text")); + draft.indexes.push(IndexInfo { + name: "by_lower_nickname".to_owned(), + keys: vec![IndexKey::Expression { + sql: "lower(nickname)".to_owned(), + descending: false, + }], + unique: false, + primary: false, + predicate: Some("nickname IS NOT NULL".to_owned()), + }); + }); + + assert_eq!( + sql(plan), + [ + "DROP INDEX \"app\".\"by_email\"", + "ALTER TABLE \"app\".\"users\" DROP COLUMN \"email\", ADD COLUMN \"nickname\" text", + "CREATE INDEX \"by_lower_nickname\" ON \"app\".\"users\" ((lower(nickname))) WHERE nickname IS NOT \ + NULL", + ] + ); + } + + fn referenced(table: &mut TableDefinition, column: &str) { + table.inbound_foreign_keys = vec![InboundForeignKey { + name: "orders_user_fk".to_owned(), + table: TableRef::new(Some("app".to_owned()), "orders"), + columns: vec!["user_id".to_owned()], + referenced_columns: vec![column.to_owned()], + }]; + } + + #[test] + fn primary_key_referenced_by_foreign_key_is_refused() { + let mut table = users(); + referenced(&mut table, "id"); + + let permissions = dialect().column_capabilities(&table, &table.columns[1]); + let plan = alter(&table, |draft| { + draft.columns[1].primary_key = true; + draft.columns[1].nullable = false; + }); + + assert_eq!( + permissions.alter_primary_key, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec![ + "orders_user_fk".to_owned() + ])) + ); + assert!( + matches!( + plan, + Err(BuildDdlError::Refused { + operation: DdlOperation::AlterPrimaryKey, + reason: RefusalReason::ReferencedByForeignKeys(_), + .. + }) + ), + "{plan:?}" + ); + } + + #[test] + fn drop_of_referenced_column_is_refused() { + let mut table = users(); + referenced(&mut table, "email"); + + let plan = alter(&table, |draft| draft.columns.retain(|column| column.name != "email")); + + assert!( + matches!( + &plan, + Err(BuildDdlError::Refused { + column, + operation: DdlOperation::DropColumn, + reason: RefusalReason::ReferencedByForeignKeys(keys), + }) if column == "email" && keys == &["orders_user_fk".to_owned()] + ), + "{plan:?}" + ); + } + + #[test] + fn an_identity_change_waits_for_its_plan() { + let plan = alter(&users(), |draft| draft.columns[2].auto_increment = true); + + assert!( + matches!( + plan, + Err(BuildDdlError::Refused { + operation: DdlOperation::AlterAutoIncrement, + reason: RefusalReason::EngineUnsupported, + .. + }) + ), + "{plan:?}" + ); + } + + #[test] + fn a_typed_type_is_refused_before_it_reaches_the_server() { + let plan = alter(&users(), |draft| { + draft.columns[2].column_type = DraftType::Typed("int; DROP TABLE users".to_owned()); + }); + + assert!(matches!(plan, Err(BuildDdlError::InvalidType { .. })), "{plan:?}"); + } + + #[test] + fn a_rename_stays_in_its_schema_and_a_drop_can_cascade() { + let drop = dialect().plan_drop_table( + &TableRef::new(Some("app".to_owned()), "users"), + DropTableOptions { + if_exists: true, + cascade: true, + }, + ); + + assert_eq!(sql(drop), ["DROP TABLE IF EXISTS \"app\".\"users\" CASCADE"]); + } + + #[test] + fn the_script_restates_identity_collation_and_generated_columns() { + let mut table = users(); + table.columns[0].info.is_auto_increment = true; + table.columns[0].attributes.identity = IdentityStyle::GeneratedAlways; + table.columns[1].attributes.collation = Some(CollationName::new(Some("pg_catalog".to_owned()), "C")); + let mut total = column("total", "integer", false); + total.info.is_generated = true; + total.attributes.generated = Some(tablepro_core::ddl::GeneratedColumn { + expression: SqlExpression::from_catalog_text("(status * 2)"), + storage: GeneratedStorage::Stored, + }); + table.columns.push(total); + + let script = sql(dialect().create_table_script(&table)); + + assert_eq!( + script, + [ + "CREATE TABLE \"app\".\"users\" (\n \"id\" integer GENERATED ALWAYS AS IDENTITY NOT NULL,\n \"email\" \ + text COLLATE \"pg_catalog\".\"C\",\n \"status\" integer,\n \"total\" integer GENERATED ALWAYS AS \ + ((status * 2)) STORED,\n PRIMARY KEY (\"id\")\n)" + ] + ); + } + + #[test] + fn every_type_suggestion_parses() { + let dialect = dialect(); + + for suggestion in dialect.type_suggestions() { + assert!(dialect.parse_type(suggestion).is_ok(), "{suggestion}"); + } + assert!(dialect.parse_type(dialect.default_column_type()).is_ok()); + } +} diff --git a/linux/crates/drivers/postgres/src/dialect.rs b/linux/crates/drivers/postgres/src/dialect.rs index 71e1879cf5..b6b15a5f5c 100644 --- a/linux/crates/drivers/postgres/src/dialect.rs +++ b/linux/crates/drivers/postgres/src/dialect.rs @@ -4,7 +4,7 @@ use tablepro_core::column::{ BoolStorage, CatalogType, ColumnInfo, ColumnKind, ColumnType, FloatWidth, IntegerKind, IntegerWidth, ReadForm, SqlExpression, SqlTypeExpr, TemporalZone, TextLength, classify_type_name, has_dynamic_storage, }; -use tablepro_core::ddl::{DdlCapabilities, DdlDialect}; +use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ BindError, BindTarget, DialectCapabilities, KeysetDirection, LiteralError, Placeholder, SqlDialect, }; @@ -39,40 +39,6 @@ impl PgDialect { } } -static PG_DDL: PgDdl = PgDdl { - capabilities: DdlCapabilities { - create_table: true, - rename_table: true, - add_column: true, - drop_column: true, - rename_column: true, - alter_column_type: true, - alter_column_nullability: true, - nullability_switch: true, - alter_column_default: true, - alter_primary_key: true, - alter_auto_increment: true, - create_index: true, - drop_index: true, - expression_index_keys: true, - partial_indexes: true, - add_foreign_key: true, - drop_foreign_key: true, - drop_table_cascade: true, - }, -}; - -#[derive(Debug)] -struct PgDdl { - capabilities: DdlCapabilities, -} - -impl DdlDialect for PgDdl { - fn capabilities(&self) -> &DdlCapabilities { - &self.capabilities - } -} - impl SqlDialect for PgDialect { fn capabilities(&self) -> DialectCapabilities { DialectCapabilities { exact_row_counts: true } @@ -181,7 +147,7 @@ impl SqlDialect for PgDialect { } fn ddl(&self) -> Option<&dyn DdlDialect> { - Some(&PG_DDL) + Some(self) } /// A column without a binary decoder is read as the server's text, diff --git a/linux/crates/drivers/postgres/src/lib.rs b/linux/crates/drivers/postgres/src/lib.rs index 7fcebd08fe..c2ed03aa50 100644 --- a/linux/crates/drivers/postgres/src/lib.rs +++ b/linux/crates/drivers/postgres/src/lib.rs @@ -12,6 +12,7 @@ mod catalog; mod codec; mod commit_resolution; mod connection; +mod ddl; mod dialect; mod driver; mod editor; diff --git a/linux/crates/drivers/postgres/tests/integration/conformance_engine.rs b/linux/crates/drivers/postgres/tests/integration/conformance_engine.rs index 5a66da309b..3e4888aca5 100644 --- a/linux/crates/drivers/postgres/tests/integration/conformance_engine.rs +++ b/linux/crates/drivers/postgres/tests/integration/conformance_engine.rs @@ -11,8 +11,8 @@ use tablepro_core::{ use tablepro_test_fixtures::{HbaMode, PostgresFixture}; use tablepro_test_support::FaultTransport; use tablepro_test_support::conformance::{ - BoundedDdlEngine, ConformanceEngine, ConformanceFailure, EditorTransactionEngine, NetworkConformanceEngine, - PooledConformanceEngine, TypedConformanceEngine, UnsupportedTypeCase, + BoundedDdlEngine, ConformanceDdlEngine, ConformanceEngine, ConformanceFailure, EditorTransactionEngine, + NetworkConformanceEngine, PooledConformanceEngine, TypedConformanceEngine, UnsupportedTypeCase, }; use crate::support::{direct, options_over, password}; @@ -156,6 +156,24 @@ impl BoundedDdlEngine for PgEngine { } } +impl ConformanceDdlEngine for PgEngine { + fn integer_type(&self) -> &'static str { + "integer" + } + + fn text_type(&self) -> &'static str { + "text" + } + + fn timestamp_type(&self) -> &'static str { + "timestamp" + } + + fn current_timestamp_expression(&self) -> &'static str { + "CURRENT_TIMESTAMP" + } +} + mod suite { use super::PgEngine; @@ -168,4 +186,9 @@ mod suite { unsupported_types: true, bounded_ddl: true, } + + tablepro_test_support::ddl_conformance_suite! { + engine: PgEngine, + docker: true, + } } diff --git a/linux/crates/drivers/sqlite/src/ddl.rs b/linux/crates/drivers/sqlite/src/ddl.rs new file mode 100644 index 0000000000..b8ba97eaa1 --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl.rs @@ -0,0 +1,567 @@ +use tablepro_core::ForeignKeyInfo; +use tablepro_core::column::ColumnDefault; +use tablepro_core::ddl::ddl_sql::{column_list, default_sql, foreign_key_sql, index_keys_sql, parenthesized}; +use tablepro_core::ddl::{ + BuildDdlError, DdlCapabilities, DdlDialect, DdlOperation, DdlPlan, DraftColumn, GeneratedStorage, SchemaObjectKind, + TableChanges, TableDefinition, TableDraft, +}; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::{IndexInfo, TableRef}; + +use crate::dialect::SqliteDialect; + +/// What SQLite's ALTER TABLE does natively. Everything else needs the +/// table rebuilt, which these plans do not do yet. +static CAPABILITIES: DdlCapabilities = DdlCapabilities { + create_table: true, + rename_table: true, + add_column: true, + drop_column: true, + rename_column: true, + alter_column_type: false, + alter_column_nullability: false, + nullability_switch: false, + alter_column_default: false, + alter_primary_key: false, + alter_auto_increment: false, + create_index: true, + drop_index: true, + expression_index_keys: true, + partial_indexes: true, + add_foreign_key: false, + drop_foreign_key: false, + column_comments: false, + drop_table_cascade: false, +}; + +const TYPE_SUGGESTIONS: &[&str] = &[ + "INTEGER", "TEXT", "REAL", "BLOB", "NUMERIC", "BOOLEAN", "DATETIME", "DATE", +]; + +/// SQLite runs a schema change inside the write's transaction, one +/// native ALTER TABLE per change. +impl DdlDialect for SqliteDialect { + fn capabilities(&self) -> &DdlCapabilities { + &CAPABILITIES + } + + fn sql_dialect(&self) -> &dyn SqlDialect { + self + } + + fn type_suggestions(&self) -> &'static [&'static str] { + TYPE_SUGGESTIONS + } + + fn default_column_type(&self) -> &'static str { + "TEXT" + } + + /// Foreign keys are declared inside CREATE TABLE, the only place + /// SQLite takes them. + fn plan_create_table(&self, draft: &TableDraft) -> Result { + draft.check_create(self)?; + let table = TableRef::new(draft.table.schema.clone(), draft.table.name.trim()); + let key = draft.primary_key(); + // AUTOINCREMENT is only legal on the column's own PRIMARY KEY + // clause, which also makes it the rowid alias. + let inline_key = match key.as_slice() { + [only] => draft + .column(only) + .filter(|column| column.auto_increment) + .map(|column| column.name.as_str()), + _ => None, + }; + let mut definitions = draft + .columns + .iter() + .map(|column| self.column_definition(column, inline_key == Some(column.name.as_str()))) + .collect::, _>>()?; + if inline_key.is_none() && !key.is_empty() { + let key: Vec = key.into_iter().map(str::to_owned).collect(); + definitions.push(format!("PRIMARY KEY ({})", column_list(self, &key))); + } + // A foreign key names its parent bare: SQLite looks for it in the + // child's own schema and refuses a qualified name. + let local = TableRef::unqualified(table.name.clone()); + definitions.extend(draft.foreign_keys.iter().map(|key| { + let key = ForeignKeyInfo { + ref_schema: None, + ..key.clone() + }; + foreign_key_sql(self, &local, &key) + })); + let mut statements = vec![format!( + "CREATE TABLE {} (\n {}\n)", + self.qualify(&table), + definitions.join(",\n ") + )]; + statements.extend( + draft + .indexes + .iter() + .filter(|index| !index.primary) + .map(|index| self.create_index(&table, index)), + ); + Ok(DdlPlan::from_sql(statements)) + } + + fn plan_alter_table(&self, original: &TableDefinition, changes: &TableChanges) -> Result { + changes.check(self, original)?; + let mut statements = Vec::new(); + // SQLite keeps a renamed table in its schema, so the new name is + // bare. + if let Some(name) = &changes.renamed_table { + statements.push(format!( + "ALTER TABLE {} RENAME TO {}", + self.qualify(&original.table), + self.quote_identifier(name) + )); + } + let table = changes.target(&original.table); + let qualified = self.qualify(&table); + // SQLite refuses to drop an indexed column, so the index goes + // first. + for index in &changes.dropped_indexes { + let index = TableRef::new(table.schema.clone(), index.name.clone()); + statements.push(format!("DROP INDEX {}", self.qualify(&index))); + } + for column in &changes.dropped_columns { + statements.push(format!( + "ALTER TABLE {qualified} DROP COLUMN {}", + self.quote_identifier(column.name()) + )); + } + for rename in &changes.renamed_columns { + statements.push(format!( + "ALTER TABLE {qualified} RENAME COLUMN {} TO {}", + self.quote_identifier(&rename.from), + self.quote_identifier(&rename.to) + )); + } + for column in &changes.added_columns { + // The rows already there would have nothing to hold. + if !column.nullable && column.default.is_none() { + return Err(BuildDdlError::Unsupported( + DdlOperation::AddRequiredColumnWithoutDefault, + )); + } + statements.push(format!( + "ALTER TABLE {qualified} ADD COLUMN {}", + self.column_definition(column, false)? + )); + } + statements.extend( + changes + .added_indexes + .iter() + .map(|index| self.create_index(&table, index)), + ); + Ok(DdlPlan::from_sql(statements)) + } + + /// The CREATE statement SQLite stored, then the stored statements of + /// the table's own indexes and triggers. Indexes SQLite made for a + /// constraint have no statement and come back with the table. + fn create_table_script(&self, definition: &TableDefinition) -> Result { + let Some(source) = &definition.source else { + return self.plan_create_table(&TableDraft::from_definition(definition)); + }; + let own = |table: &str| table.eq_ignore_ascii_case(&definition.table.name); + let dependents = source + .dependents + .iter() + .filter(|object| matches!(object.kind, SchemaObjectKind::Index | SchemaObjectKind::Trigger)) + .filter(|object| own(&object.table)) + .map(|object| object.sql.clone()); + Ok(DdlPlan::from_sql( + std::iter::once(source.create_statement.clone()).chain(dependents), + )) + } +} + +impl SqliteDialect { + fn column_definition(&self, column: &DraftColumn, inline_key: bool) -> Result { + let resolved = column.column_type.resolve(self, &column.name)?; + let mut parts = vec![self.quote_identifier(&column.name), resolved.sql.clone()]; + if inline_key { + parts.push("PRIMARY KEY AUTOINCREMENT".to_owned()); + } + if !column.nullable { + parts.push("NOT NULL".to_owned()); + } + let generated = column + .original + .as_ref() + .and_then(|original| original.attributes.generated.as_ref()); + if let Some(generated) = generated { + let storage = match generated.storage { + GeneratedStorage::Stored => "STORED", + GeneratedStorage::Virtual => "VIRTUAL", + }; + parts.push(format!( + "GENERATED ALWAYS AS {} {storage}", + parenthesized(generated.expression.as_sql()) + )); + } else if let Some(default) = default_sql(self, &column.name, &column.default, &resolved.column_type)? { + parts.push(format!("DEFAULT {}", default_clause(&column.default, &default))); + } + Ok(parts.join(" ")) + } + + /// The index takes the schema and the table stays bare: SQLite + /// creates an index in its table's schema and reads `ON` unqualified. + fn create_index(&self, table: &TableRef, index: &IndexInfo) -> String { + let unique = if index.unique { "UNIQUE " } else { "" }; + let name = TableRef::new(table.schema.clone(), index.name.clone()); + let mut sql = format!( + "CREATE {unique}INDEX {} ON {} ({})", + self.qualify(&name), + self.quote_identifier(&table.name), + index_keys_sql(self, index) + ); + if let Some(predicate) = &index.predicate { + sql.push_str(&format!(" WHERE {predicate}")); + } + sql + } +} + +/// An expression default goes in parentheses, apart from the three +/// CURRENT_ keywords SQLite takes bare. +fn default_clause(default: &ColumnDefault, sql: &str) -> String { + let keyword = ["CURRENT_TIME", "CURRENT_DATE", "CURRENT_TIMESTAMP"] + .iter() + .any(|keyword| sql.trim().eq_ignore_ascii_case(keyword)); + match default { + ColumnDefault::Expression(_) if !keyword => parenthesized(sql), + _ => sql.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnInfo, SqlExpression}; + use tablepro_core::ddl::ReferentialAction; + use tablepro_core::ddl::{ + ColumnAttributes, ColumnDefinition, DraftType, InboundForeignKey, Permission, RefusalReason, + SchemaObjectSource, TableSource, diff_table, + }; + use tablepro_core::meta::IndexKey; + use tablepro_core::value::Value; + + use super::*; + use crate::catalog::SqliteVersion; + + fn dialect() -> SqliteDialect { + SqliteDialect::new(SqliteVersion::new(3, 46, 0)) + } + + fn typed(name: &str, declaration: &str) -> DraftColumn { + DraftColumn::new(name, DraftType::Typed(declaration.to_owned())) + } + + fn column(name: &str, declaration: &str, primary_key: bool) -> ColumnDefinition { + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type: dialect().parse_type(declaration).expect("a SQLite type"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: tablepro_core::column::ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) + } + + fn users() -> TableDefinition { + TableDefinition { + table: TableRef::unqualified("users"), + columns: vec![column("id", "INTEGER", true), column("email", "TEXT", false)], + primary_key: Some(tablepro_core::ddl::PrimaryKeyConstraint { + name: None, + columns: vec!["id".to_owned()], + }), + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } + } + + fn alter(original: &TableDefinition, edit: impl FnOnce(&mut TableDraft)) -> Result { + let mut draft = TableDraft::from_definition(original); + edit(&mut draft); + dialect().plan_alter_table(original, &diff_table(original, &draft)) + } + + fn sql(plan: Result) -> Vec { + plan.expect("the change plans") + .sql() + .into_iter() + .map(str::to_owned) + .collect() + } + + #[test] + fn plan_create_table_simple() { + let mut draft = TableDraft::new(TableRef::unqualified("notes")); + let mut id = typed("id", "INTEGER"); + id.primary_key = true; + id.nullable = false; + id.auto_increment = true; + let mut body = typed("body", "TEXT"); + body.nullable = false; + body.default = ColumnDefault::Literal(Value::Text("it's".to_owned())); + let mut created = typed("created_at", "TEXT"); + created.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("CURRENT_TIMESTAMP")); + let mut token = typed("token", "TEXT"); + token.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("lower(hex(randomblob(4)))")); + draft.columns = vec![id, body, created, token]; + + assert_eq!( + sql(dialect().plan_create_table(&draft)), + [ + "CREATE TABLE \"notes\" (\n \"id\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,\n \"body\" TEXT NOT \ + NULL DEFAULT 'it''s',\n \"created_at\" TEXT DEFAULT CURRENT_TIMESTAMP,\n \"token\" TEXT DEFAULT \ + (lower(hex(randomblob(4))))\n)" + ] + ); + } + + #[test] + fn plan_create_table_composite_key_index_and_foreign_key() { + let mut draft = TableDraft::new(TableRef::new(Some("main".to_owned()), "lines")); + let mut order = typed("order_id", "INTEGER"); + order.primary_key = true; + order.nullable = false; + let mut line = typed("line", "INTEGER"); + line.primary_key = true; + line.nullable = false; + draft.columns = vec![order, line, typed("sku", "TEXT")]; + draft.indexes = vec![IndexInfo { + name: "by_sku".to_owned(), + keys: vec![IndexKey::Column { + name: "sku".to_owned(), + descending: false, + }], + unique: true, + primary: false, + predicate: Some("sku IS NOT NULL".to_owned()), + }]; + draft.foreign_keys = vec![ForeignKeyInfo { + name: "lines_order_fk".to_owned(), + columns: vec!["order_id".to_owned()], + ref_schema: None, + ref_table: "orders".to_owned(), + ref_columns: vec!["id".to_owned()], + on_delete: Some(ReferentialAction::Cascade), + on_update: None, + }]; + + assert_eq!( + sql(dialect().plan_create_table(&draft)), + [ + "CREATE TABLE \"main\".\"lines\" (\n \"order_id\" INTEGER NOT NULL,\n \"line\" INTEGER NOT \ + NULL,\n \"sku\" TEXT,\n PRIMARY KEY (\"order_id\", \"line\"),\n CONSTRAINT \"lines_order_fk\" \ + FOREIGN KEY (\"order_id\") REFERENCES \"orders\" (\"id\") ON DELETE CASCADE\n)", + "CREATE UNIQUE INDEX \"main\".\"by_sku\" ON \"lines\" (\"sku\") WHERE sku IS NOT NULL", + ] + ); + } + + #[test] + fn plan_alter_table_takes_the_native_changes_and_refuses_the_rest() { + let native = alter(&users(), |draft| { + draft.table.name = "members".to_owned(); + draft.columns[1].name = "mail".to_owned(); + draft.columns.push(typed("nickname", "TEXT")); + }); + let nullability = alter(&users(), |draft| draft.columns[1].nullable = false); + let typed_change = alter(&users(), |draft| { + draft.columns[1].column_type = DraftType::Typed("BLOB".to_owned()); + }); + let default = alter(&users(), |draft| { + draft.columns[1].default = ColumnDefault::Literal(Value::Text("x".to_owned())); + }); + + assert_eq!( + sql(native), + [ + "ALTER TABLE \"users\" RENAME TO \"members\"", + "ALTER TABLE \"members\" RENAME COLUMN \"email\" TO \"mail\"", + "ALTER TABLE \"members\" ADD COLUMN \"nickname\" TEXT", + ] + ); + for refused in [nullability, typed_change, default] { + assert!( + matches!( + refused, + Err(BuildDdlError::Refused { + reason: RefusalReason::EngineUnsupported, + .. + }) + ), + "{refused:?}" + ); + } + } + + #[test] + fn a_required_column_without_a_default_cannot_be_added() { + let plan = alter(&users(), |draft| { + let mut required = typed("nickname", "TEXT"); + required.nullable = false; + draft.columns.push(required); + }); + + assert_eq!( + plan, + Err(BuildDdlError::Unsupported( + DdlOperation::AddRequiredColumnWithoutDefault + )) + ); + } + + #[test] + fn an_indexed_column_drop_drops_the_index_first() { + let mut table = users(); + table.indexes = vec![IndexInfo { + name: "by_email".to_owned(), + keys: vec![IndexKey::Column { + name: "email".to_owned(), + descending: false, + }], + unique: false, + primary: false, + predicate: None, + }]; + + let plan = alter(&table, |draft| { + draft.indexes.clear(); + draft.columns.retain(|column| column.name != "email"); + }); + + assert_eq!( + sql(plan), + ["DROP INDEX \"by_email\"", "ALTER TABLE \"users\" DROP COLUMN \"email\""] + ); + } + + #[test] + fn primary_key_referenced_by_foreign_key_is_refused() { + let mut table = users(); + // SQLite reports a reference to the primary key with a NULL + // target, which the catalogue resolves to the key's columns. + table.inbound_foreign_keys = vec![InboundForeignKey { + name: "fk_orders_0".to_owned(), + table: TableRef::unqualified("orders"), + columns: vec!["user_id".to_owned()], + referenced_columns: vec!["id".to_owned()], + }]; + + let permissions = dialect().column_capabilities(&table, &table.columns[1]); + let id = dialect().column_capabilities(&table, &table.columns[0]); + + assert_eq!( + permissions.alter_primary_key, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec!["fk_orders_0".to_owned()])) + ); + assert_eq!( + id.drop, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec!["fk_orders_0".to_owned()])) + ); + } + + #[test] + fn drop_of_referenced_column_is_refused() { + let mut table = users(); + table.inbound_foreign_keys = vec![InboundForeignKey { + name: "fk_orders_0".to_owned(), + table: TableRef::unqualified("orders"), + columns: vec!["user_email".to_owned()], + referenced_columns: vec!["email".to_owned()], + }]; + + let plan = alter(&table, |draft| draft.columns.retain(|column| column.name != "email")); + + assert!( + matches!( + plan, + Err(BuildDdlError::Refused { + operation: DdlOperation::DropColumn, + reason: RefusalReason::ReferencedByForeignKeys(_), + .. + }) + ), + "{plan:?}" + ); + } + + #[test] + fn the_script_replays_the_stored_table_and_its_own_dependents() { + let mut table = users(); + let object = |kind, name: &str, owner: &str, sql: &str| SchemaObjectSource { + kind, + name: name.to_owned(), + table: owner.to_owned(), + sql: sql.to_owned(), + }; + table.source = Some(TableSource { + create_statement: "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)".to_owned(), + dependents: vec![ + object( + SchemaObjectKind::Index, + "by_email", + "Users", + "CREATE INDEX by_email ON users (email)", + ), + object( + SchemaObjectKind::Index, + "by_total", + "orders", + "CREATE INDEX by_total ON orders (total)", + ), + object( + SchemaObjectKind::Trigger, + "stamp", + "users", + "CREATE TRIGGER stamp AFTER INSERT ON users BEGIN SELECT 1; END", + ), + object(SchemaObjectKind::View, "v", "v", "CREATE VIEW v AS SELECT * FROM users"), + ], + }); + + assert_eq!( + sql(dialect().create_table_script(&table)), + [ + "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)", + "CREATE INDEX by_email ON users (email)", + "CREATE TRIGGER stamp AFTER INSERT ON users BEGIN SELECT 1; END", + ] + ); + } + + #[test] + fn foreign_keys_and_comments_wait_for_the_rebuild() { + let dialect = dialect(); + let capabilities = DdlDialect::capabilities(&dialect); + + assert!(!capabilities.add_foreign_key && !capabilities.drop_foreign_key); + assert!(!capabilities.column_comments); + assert!(capabilities.partial_indexes && capabilities.expression_index_keys); + } + + #[test] + fn every_type_suggestion_parses() { + let dialect = dialect(); + + for suggestion in dialect.type_suggestions() { + assert!(dialect.parse_type(suggestion).is_ok(), "{suggestion}"); + } + assert!(dialect.parse_type(dialect.default_column_type()).is_ok()); + } +} diff --git a/linux/crates/drivers/sqlite/src/dialect.rs b/linux/crates/drivers/sqlite/src/dialect.rs index dfaf3d3005..275293b296 100644 --- a/linux/crates/drivers/sqlite/src/dialect.rs +++ b/linux/crates/drivers/sqlite/src/dialect.rs @@ -1,5 +1,5 @@ use tablepro_core::column::{ColumnInfo, ColumnKind, ColumnType, SqlExpression}; -use tablepro_core::ddl::{DdlCapabilities, DdlDialect}; +use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ BindError, BindTarget, DialectCapabilities, KeysetDirection, LikeCase, LikeForm, LiteralError, Placeholder, SqlDialect, @@ -20,43 +20,6 @@ pub struct SqliteDialect { version: SqliteVersion, } -/// What the structure tab can change on SQLite without rebuilding the -/// table. Everything ALTER TABLE cannot do is off until the rebuild -/// plans exist. -static DDL: SqliteDdl = SqliteDdl { - capabilities: DdlCapabilities { - create_table: true, - rename_table: true, - add_column: true, - drop_column: true, - rename_column: true, - alter_column_type: false, - alter_column_nullability: false, - nullability_switch: false, - alter_column_default: false, - alter_primary_key: false, - alter_auto_increment: false, - create_index: true, - drop_index: true, - expression_index_keys: true, - partial_indexes: true, - add_foreign_key: false, - drop_foreign_key: false, - drop_table_cascade: false, - }, -}; - -#[derive(Debug)] -struct SqliteDdl { - capabilities: DdlCapabilities, -} - -impl DdlDialect for SqliteDdl { - fn capabilities(&self) -> &DdlCapabilities { - &self.capabilities - } -} - impl SqliteDialect { pub fn new(version: SqliteVersion) -> Self { Self { version } @@ -198,7 +161,7 @@ impl SqlDialect for SqliteDialect { } fn ddl(&self) -> Option<&dyn DdlDialect> { - Some(&DDL) + Some(self) } } diff --git a/linux/crates/drivers/sqlite/src/lib.rs b/linux/crates/drivers/sqlite/src/lib.rs index a4cba99071..be5691f874 100644 --- a/linux/crates/drivers/sqlite/src/lib.rs +++ b/linux/crates/drivers/sqlite/src/lib.rs @@ -6,6 +6,7 @@ mod capabilities; mod catalog; mod connection; mod create_file; +mod ddl; mod deadline; mod decode; mod dialect; diff --git a/linux/crates/drivers/sqlite/tests/integration/conformance_engine.rs b/linux/crates/drivers/sqlite/tests/integration/conformance_engine.rs index c287e7b26e..593fd87183 100644 --- a/linux/crates/drivers/sqlite/tests/integration/conformance_engine.rs +++ b/linux/crates/drivers/sqlite/tests/integration/conformance_engine.rs @@ -13,7 +13,7 @@ use tablepro_core::{ WorkspaceSettings, }; use tablepro_test_support::conformance::{ - ConformanceEngine, ConformanceFailure, EditorTransactionEngine, PooledConformanceEngine, + ConformanceDdlEngine, ConformanceEngine, ConformanceFailure, EditorTransactionEngine, PooledConformanceEngine, }; use tempfile::TempDir; @@ -113,6 +113,29 @@ impl EditorTransactionEngine for SqliteEngine { } } +impl ConformanceDdlEngine for SqliteEngine { + fn integer_type(&self) -> &'static str { + "INTEGER" + } + + fn text_type(&self) -> &'static str { + "TEXT" + } + + fn timestamp_type(&self) -> &'static str { + "TEXT" + } + + fn current_timestamp_expression(&self) -> &'static str { + "CURRENT_TIMESTAMP" + } +} + +tablepro_test_support::ddl_conformance_suite! { + engine: SqliteEngine, + docker: false, +} + // Every stored value is one of five storage classes and all five are // decoded, so there is no unsupported type to list. SQLite has no // server statement timeout, so no case bounds DDL by one. diff --git a/linux/crates/test-support/src/conformance/cases/ddl.rs b/linux/crates/test-support/src/conformance/cases/ddl.rs new file mode 100644 index 0000000000..c472c8f6d4 --- /dev/null +++ b/linux/crates/test-support/src/conformance/cases/ddl.rs @@ -0,0 +1,643 @@ +//! Structure changes planned through the connection's own DDL dialect +//! and run as the structure tab runs them, then read back through +//! `fetch_table_definition`. + +use tablepro_core::column::ColumnDefault; +use tablepro_core::ddl::{ + BuildDdlError, ColumnDefinition, DdlDialect, DdlOperation, DdlPlan, DraftColumn, DraftType, DropTableOptions, + Permission, RefusalReason, TableDefinition, TableDraft, diff_table, +}; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::{IndexInfo, IndexKey, ObjectKind, ObjectScope}; +use tablepro_core::value::Value; +use tablepro_core::{ + Connection, DriverError, ForeignKeyInfo, ForeignKeyMode, TableRef, WriteAtomicity, WriteBatch, WriteKind, + WriteReport, +}; + +use crate::conformance::support::{CALL_DEADLINE, connect, expect, unexpected, unique, within, writable}; +use crate::conformance::{ConformanceDdlEngine, ConformanceFailure}; + +const APPLY_CASE: &str = "ddl_plans_apply_and_read_back"; +const FAILED_CASE: &str = "failed_ddl_batch_outcome_matches_ddl_atomicity"; +const SCRIPT_CASE: &str = "create_table_script_recreates_the_table"; + +/// Case 1: each structure change the engine offers applies and reads +/// back as drafted, and each one it refuses is refused by the plan +/// with a reason, never sent. +pub async fn ddl_plans_apply_and_read_back(engine: E) -> Result<(), ConformanceFailure> { + let case = APPLY_CASE; + let connection = connect(case, &engine, writable()).await?; + let dialect = connection.dialect(); + let ddl = ddl_of(case, dialect.as_ref())?; + let capabilities = *ddl.capabilities(); + let parent = engine.scratch_table(&unique("ddl_parent")); + let child = engine.scratch_table(&unique("ddl_child")); + + create( + case, + &engine, + connection.as_ref(), + ddl, + &parent_draft(case, &engine, dialect.as_ref(), &parent)?, + ) + .await?; + create(case, &engine, connection.as_ref(), ddl, &child_draft(&engine, &child)).await?; + let mut table = definition(connection.as_ref(), &parent).await?; + matches_draft( + case, + "create", + &table, + &parent_draft(case, &engine, dialect.as_ref(), &parent)?, + )?; + + let step = Step { + case, + connection: connection.as_ref(), + ddl, + }; + let note = DraftColumn::new("note", DraftType::Typed(engine.text_type().to_owned())); + step.apply( + &mut table, + "add column", + table_permission(capabilities.add_column), + |draft| draft.columns.push(note), + ) + .await?; + + let permission = column_permission(ddl, &table, "note", |permissions| permissions.rename); + step.apply(&mut table, "rename column", permission, |draft| { + rename(draft, "note", "remark") + }) + .await?; + let remark = if table.column("remark").is_some() { + "remark" + } else { + "note" + }; + + let permission = column_permission(ddl, &table, remark, |permissions| permissions.alter_nullability); + step.apply(&mut table, "change nullability", permission, |draft| { + edit_column(draft, remark, |column| column.nullable = false) + }) + .await?; + + let permission = column_permission(ddl, &table, remark, |permissions| permissions.alter_default); + step.apply(&mut table, "change default", permission, |draft| { + edit_column(draft, remark, |column| { + column.default = ColumnDefault::Literal(Value::Text("none".to_owned())); + }) + }) + .await?; + + let index = IndexInfo { + name: format!("{}_label", parent.name), + keys: vec![IndexKey::Column { + name: "label".to_owned(), + descending: false, + }], + unique: false, + primary: false, + predicate: None, + }; + step.apply( + &mut table, + "create index", + table_permission(capabilities.create_index), + |draft| draft.indexes.push(index), + ) + .await?; + + let mut child_table = definition(connection.as_ref(), &child).await?; + let key = ForeignKeyInfo { + name: format!("{}_parent_fk", child.name), + columns: vec!["parent_id".to_owned()], + ref_schema: parent.schema.clone(), + ref_table: parent.name.clone(), + ref_columns: vec!["id".to_owned()], + on_delete: None, + on_update: None, + }; + let key_name = key.name.clone(); + let added = step + .apply( + &mut child_table, + "add foreign key", + table_permission(capabilities.add_foreign_key), + |draft| draft.foreign_keys.push(key), + ) + .await?; + if added { + table = definition(connection.as_ref(), &parent).await?; + let permission = column_permission(ddl, &table, "label", |permissions| permissions.alter_primary_key); + let names_the_key = matches!( + &permission, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(keys)) if keys.contains(&key_name) + ); + expect(case, names_the_key, || { + format!("the referenced primary key reads as {permission:?}, not held by {key_name}") + })?; + } + + let permission = column_permission(ddl, &table, remark, |permissions| permissions.drop); + step.apply(&mut table, "drop column", permission, |draft| { + draft.columns.retain(|column| column.name != remark) + }) + .await?; + + let drop = DropTableOptions { + if_exists: false, + cascade: capabilities.drop_table_cascade, + }; + for dropped in [&child, &parent] { + let plan = ddl + .plan_drop_table(dropped, drop) + .map_err(|error| planned(case, "drop table", error))?; + run(connection.as_ref(), dropped, plan).await?; + } + let objects = connection + .list_objects(&ObjectScope::DefaultSchema, within(CALL_DEADLINE)) + .await?; + let left: Vec<&str> = objects + .iter() + .filter(|object| object.kind == ObjectKind::Table) + .filter(|object| object.name == parent.name || object.name == child.name) + .map(|object| object.name.as_str()) + .collect(); + expect(case, left.is_empty(), || { + format!("{left:?} are still listed after the drop") + })?; + connection.close().await?; + Ok(()) +} + +/// Case 2: a batch of two plans whose second statement the server +/// rejects leaves what the engine's DDL atomicity promises: nothing on +/// an engine whose schema changes are transactional, the first plan on +/// one that applies them statement by statement. +pub async fn failed_ddl_batch_outcome_matches_ddl_atomicity( + engine: E, +) -> Result<(), ConformanceFailure> { + let case = FAILED_CASE; + let atomicity = E::driver().capabilities().ddl_writes; + let connection = connect(case, &engine, writable()).await?; + let dialect = connection.dialect(); + let ddl = ddl_of(case, dialect.as_ref())?; + let parent = engine.scratch_table(&unique("ddl_failed")); + create( + case, + &engine, + connection.as_ref(), + ddl, + &parent_draft(case, &engine, dialect.as_ref(), &parent)?, + ) + .await?; + let original = definition(connection.as_ref(), &parent).await?; + let text = || DraftType::Typed(engine.text_type().to_owned()); + + let mut draft = TableDraft::from_definition(&original); + draft.columns.push(DraftColumn::new("c1", text())); + let first = ddl + .plan_alter_table(&original, &diff_table(&original, &draft)) + .map_err(|error| planned(case, "add c1", error))?; + // Planned against a definition that lacks `label`, so the server + // refuses to add a column the table already has. + let mut stale = original.clone(); + stale.columns.retain(|column| column.name() != "label"); + let mut again = TableDraft::from_definition(&stale); + again.columns.push(DraftColumn::new("label", text())); + let second = ddl + .plan_alter_table(&stale, &diff_table(&stale, &again)) + .map_err(|error| planned(case, "add label again", error))?; + expect(case, first.len() == 1 && second.len() == 1, || { + format!( + "each plan should be one statement: {:?} and {:?}", + first.sql(), + second.sql() + ) + })?; + + let steps = first.into_steps().into_iter().chain(second.into_steps()).collect(); + let batch = WriteBatch::new( + WriteKind::SchemaChanges, + parent.clone(), + steps, + ForeignKeyMode::Enforced, + ); + let outcome = connection.write_batch(&batch, within(CALL_DEADLINE)).await; + let first_kept = match (atomicity, &outcome) { + (WriteAtomicity::Transactional, Err(DriverError::RolledBack { statement_index: 1, .. })) => false, + (WriteAtomicity::StatementByStatement, Err(DriverError::PartiallyApplied { applied: 1, .. })) => true, + _ => { + return Err(unexpected( + case, + format!("a {atomicity:?} engine answered a schema batch failing at statement 1 with {outcome:?}"), + )); + } + }; + let after = definition(connection.as_ref(), &parent).await?; + let has_c1 = after.column("c1").is_some(); + expect(case, has_c1 == first_kept, || { + format!( + "c1 is {} after the failed batch", + if has_c1 { "present" } else { "absent" } + ) + })?; + drop_table(case, connection.as_ref(), ddl, &parent).await?; + connection.close().await?; + Ok(()) +} + +/// Case 3: the script for a table, run after the table is dropped, +/// recreates its columns and its index keys as they were. +pub async fn create_table_script_recreates_the_table( + engine: E, +) -> Result<(), ConformanceFailure> { + let case = SCRIPT_CASE; + let connection = connect(case, &engine, writable()).await?; + let dialect = connection.dialect(); + let ddl = ddl_of(case, dialect.as_ref())?; + let parent = engine.scratch_table(&unique("ddl_script")); + create( + case, + &engine, + connection.as_ref(), + ddl, + &parent_draft(case, &engine, dialect.as_ref(), &parent)?, + ) + .await?; + let mut table = definition(connection.as_ref(), &parent).await?; + if ddl.capabilities().create_index { + let index = IndexInfo { + name: format!("{}_label", parent.name), + keys: vec![IndexKey::Column { + name: "label".to_owned(), + descending: true, + }], + unique: false, + primary: false, + predicate: None, + }; + let step = Step { + case, + connection: connection.as_ref(), + ddl, + }; + step.apply(&mut table, "create index", Permission::Allowed, |draft| { + draft.indexes.push(index) + }) + .await?; + } + + let script = ddl + .create_table_script(&table) + .map_err(|error| planned(case, "create table script", error))?; + drop_table(case, connection.as_ref(), ddl, &parent).await?; + run(connection.as_ref(), &parent, script).await?; + let recreated = definition(connection.as_ref(), &parent).await?; + + let shape = + |definition: &TableDefinition| -> Vec { definition.columns.iter().map(ColumnShape::of).collect() }; + let (before, after) = (shape(&table), shape(&recreated)); + expect(case, before == after, || { + format!("the columns were {before:#?} and came back {after:#?}") + })?; + let (before, after) = (secondary_indexes(&table), secondary_indexes(&recreated)); + expect(case, before == after, || { + format!("the indexes were {before:?} and came back {after:?}") + })?; + drop_table(case, connection.as_ref(), ddl, &parent).await?; + connection.close().await?; + Ok(()) +} + +fn ddl_of<'a>(case: &'static str, dialect: &'a dyn SqlDialect) -> Result<&'a dyn DdlDialect, ConformanceFailure> { + dialect + .ddl() + .ok_or_else(|| unexpected(case, "the connection's dialect has no DDL dialect")) +} + +fn planned(case: &'static str, step: &str, error: BuildDdlError) -> ConformanceFailure { + unexpected(case, format!("{step} did not plan: {error}")) +} + +/// `id` keyed, `label` with a literal default that needs quoting, and +/// `created_at` with the engine's own current-timestamp expression. +fn parent_draft( + case: &'static str, + engine: &E, + dialect: &dyn SqlDialect, + table: &TableRef, +) -> Result { + let mut id = DraftColumn::new("id", DraftType::Typed(engine.integer_type().to_owned())); + id.primary_key = true; + id.nullable = false; + let mut label = DraftColumn::new("label", DraftType::Typed(engine.text_type().to_owned())); + label.nullable = false; + label.default = ColumnDefault::Literal(Value::Text("it's".to_owned())); + let mut created = DraftColumn::new("created_at", DraftType::Typed(engine.timestamp_type().to_owned())); + let now = dialect + .parse_expression(engine.current_timestamp_expression()) + .map_err(|error| unexpected(case, format!("the current-timestamp expression did not parse: {error}")))?; + created.default = ColumnDefault::Expression(now); + let mut draft = TableDraft::new(table.clone()); + draft.columns = vec![id, label, created]; + Ok(draft) +} + +fn child_draft(engine: &E, table: &TableRef) -> TableDraft { + let mut id = DraftColumn::new("id", DraftType::Typed(engine.integer_type().to_owned())); + id.primary_key = true; + id.nullable = false; + let parent_id = DraftColumn::new("parent_id", DraftType::Typed(engine.integer_type().to_owned())); + let mut draft = TableDraft::new(table.clone()); + draft.columns = vec![id, parent_id]; + draft +} + +/// Through the plan, or through the engine's own SQL where the dialect +/// creates no tables, which the plan has to say rather than send. +async fn create( + case: &'static str, + engine: &E, + connection: &dyn Connection, + ddl: &dyn DdlDialect, + draft: &TableDraft, +) -> Result<(), ConformanceFailure> { + let plan = match (ddl.capabilities().create_table, ddl.plan_create_table(draft)) { + (true, Ok(plan)) => plan, + (false, Err(BuildDdlError::Unsupported(DdlOperation::CreateTable))) => { + let sql = engine + .create_table_sql(ddl.sql_dialect(), &draft.table, &draft.columns) + .ok_or_else(|| unexpected(case, "the engine creates no tables and gave no CREATE TABLE of its own"))?; + DdlPlan::from_sql([sql]) + } + (allowed, outcome) => { + return Err(unexpected( + case, + format!("create_table is {allowed} and the plan answered {outcome:?}"), + )); + } + }; + run(connection, &draft.table, plan).await?; + Ok(()) +} + +async fn run(connection: &dyn Connection, table: &TableRef, plan: DdlPlan) -> Result { + connection + .write_batch(&plan.into_write_batch(table.clone()), within(CALL_DEADLINE)) + .await +} + +async fn definition(connection: &dyn Connection, table: &TableRef) -> Result { + Ok(connection.fetch_table_definition(table, within(CALL_DEADLINE)).await?) +} + +async fn drop_table( + case: &'static str, + connection: &dyn Connection, + ddl: &dyn DdlDialect, + table: &TableRef, +) -> Result<(), ConformanceFailure> { + let plan = ddl + .plan_drop_table(table, DropTableOptions::default()) + .map_err(|error| planned(case, "drop table", error))?; + run(connection, table, plan).await?; + Ok(()) +} + +fn table_permission(allowed: bool) -> Permission { + Permission::unless(allowed, RefusalReason::EngineUnsupported) +} + +fn column_permission( + ddl: &dyn DdlDialect, + table: &TableDefinition, + column: &str, + pick: impl FnOnce(tablepro_core::ddl::ColumnCapabilities) -> Permission, +) -> Permission { + match table.column(column) { + Some(definition) => pick(ddl.column_capabilities(table, definition)), + None => Permission::Refused(RefusalReason::UnreadableDefinition), + } +} + +fn rename(draft: &mut TableDraft, from: &str, to: &str) { + edit_column(draft, from, |column| column.name = to.to_owned()); +} + +fn edit_column(draft: &mut TableDraft, name: &str, edit: impl FnOnce(&mut DraftColumn)) { + if let Some(column) = draft.columns.iter_mut().find(|column| column.name == name) { + edit(column); + } +} + +/// One edit of an existing table, planned against its definition as +/// last read. +struct Step<'a> { + case: &'static str, + connection: &'a dyn Connection, + ddl: &'a dyn DdlDialect, +} + +impl Step<'_> { + /// Whether the change was made. An allowed change must plan, apply + /// and read back as drafted; a refused one must be refused by the + /// plan too. + async fn apply( + &self, + table: &mut TableDefinition, + step: &str, + permission: Permission, + edit: impl FnOnce(&mut TableDraft), + ) -> Result { + let mut draft = TableDraft::from_definition(table); + edit(&mut draft); + let planned = self.ddl.plan_alter_table(table, &diff_table(table, &draft)); + match (&permission, planned) { + (Permission::Allowed, Ok(plan)) => { + run(self.connection, &table.table, plan).await?; + *table = definition(self.connection, &draft.table).await?; + matches_draft(self.case, step, table, &draft)?; + Ok(true) + } + (Permission::Refused(_), Err(BuildDdlError::Unsupported(_) | BuildDdlError::Refused { .. })) => Ok(false), + (_, outcome) => Err(unexpected( + self.case, + format!("{step}: the capabilities say {permission:?} and the plan answered {outcome:?}"), + )), + } + } +} + +/// Names and order, nullability and defaults of the columns, the keys of +/// every index the draft holds and every foreign key it declares. The +/// server may add an index of its own, as MySQL does for a foreign key. +fn matches_draft( + case: &'static str, + step: &str, + table: &TableDefinition, + draft: &TableDraft, +) -> Result<(), ConformanceFailure> { + let names: Vec<&str> = table.columns.iter().map(ColumnDefinition::name).collect(); + let drafted: Vec<&str> = draft.columns.iter().map(|column| column.name.as_str()).collect(); + expect(case, names == drafted, || { + format!("after {step} the columns are {names:?}, not {drafted:?}") + })?; + for (column, drafted) in table.columns.iter().zip(&draft.columns) { + expect(case, column.info.nullable == drafted.nullable, || { + format!("after {step} {} has nullable {}", drafted.name, column.info.nullable) + })?; + expect(case, same_default(&column.info.default, &drafted.default), || { + format!( + "after {step} {} reads its default as {:?}, not {:?}", + drafted.name, column.info.default, drafted.default + ) + })?; + } + for index in draft.indexes.iter().filter(|index| !index.primary) { + let read = table.indexes.iter().find(|read| read.name == index.name); + expect(case, read.is_some_and(|read| read.keys == index.keys), || { + format!("after {step} index {} reads as {read:?}", index.name) + })?; + } + for key in &draft.foreign_keys { + let read = table.foreign_keys.iter().any(|read| { + read.columns == key.columns && read.ref_table == key.ref_table && read.ref_columns == key.ref_columns + }); + expect(case, read, || { + format!( + "after {step} foreign key {} is not in {:?}", + key.name, table.foreign_keys + ) + })?; + } + Ok(()) +} + +/// A server keeps an expression in its own spelling: SQL Server wraps +/// it in parentheses and MariaDB adds them to a bare function, so the +/// comparison leaves out case, spaces and parentheses. +fn same_default(read: &ColumnDefault, drafted: &ColumnDefault) -> bool { + let normal = |text: &str| -> String { + text.chars() + .filter(|character| !character.is_whitespace() && !matches!(character, '(' | ')')) + .flat_map(char::to_lowercase) + .collect() + }; + match (read, drafted) { + (ColumnDefault::None, ColumnDefault::None) => true, + (ColumnDefault::Literal(read), ColumnDefault::Literal(drafted)) => read == drafted, + (ColumnDefault::Expression(read), ColumnDefault::Expression(drafted)) => { + normal(read.as_sql()) == normal(drafted.as_sql()) + } + _ => false, + } +} + +/// What a recreated column has to keep. The SQL Server default +/// constraint's name is left out: the server names it afresh. +#[derive(Debug, PartialEq)] +struct ColumnShape { + name: String, + declaration: Option, + nullable: bool, + primary_key: bool, + auto_increment: bool, + generated: bool, + default: ColumnDefault, + comment: Option, +} + +impl ColumnShape { + fn of(column: &ColumnDefinition) -> Self { + let info = &column.info; + Self { + name: info.name.clone(), + declaration: info + .column_type + .catalog + .as_ref() + .map(|catalog| catalog.declaration.as_sql().to_owned()), + nullable: info.nullable, + primary_key: info.primary_key, + auto_increment: info.is_auto_increment, + generated: info.is_generated, + default: info.default.clone(), + comment: info.comment.clone(), + } + } +} + +fn secondary_indexes(table: &TableDefinition) -> Vec<(String, Vec)> { + let mut indexes: Vec<(String, Vec)> = table + .indexes + .iter() + .filter(|index| !index.primary) + .map(|index| (index.name.clone(), index.keys.clone())) + .collect(); + indexes.sort_by(|a, b| a.0.cmp(&b.0)); + indexes +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::SqlExpression; + + use super::*; + use crate::ConnectionScript; + use crate::conformance::ConformanceDdlEngine; + use crate::conformance::fake_engine::{FakeEngine, FakeScenario}; + + struct NoDdl; + + impl FakeScenario for NoDdl { + fn script() -> ConnectionScript { + FakeEngine::::base_script() + } + } + + impl ConformanceDdlEngine for FakeEngine { + fn integer_type(&self) -> &'static str { + "integer" + } + + fn text_type(&self) -> &'static str { + "text" + } + + fn timestamp_type(&self) -> &'static str { + "timestamp" + } + + fn current_timestamp_expression(&self) -> &'static str { + "CURRENT_TIMESTAMP" + } + } + + #[tokio::test] + async fn a_connection_without_a_ddl_dialect_fails_the_case() { + let outcome = ddl_plans_apply_and_read_back(FakeEngine::::new()).await; + + assert!( + matches!(&outcome, Err(ConformanceFailure::Expectation { case: APPLY_CASE, detail }) if detail.contains("no DDL dialect")), + "{outcome:?}" + ); + } + + #[test] + fn a_server_spelled_expression_is_the_same_default() { + let expression = |text: &str| ColumnDefault::Expression(SqlExpression::from_catalog_text(text)); + + assert!(same_default(&expression("(getdate())"), &expression("getdate()"))); + assert!(same_default( + &expression("current_timestamp()"), + &expression("CURRENT_TIMESTAMP") + )); + assert!(!same_default(&expression("now()"), &expression("today()"))); + assert!(!same_default( + &ColumnDefault::Literal(Value::Text("it's".to_owned())), + &expression("'it''s'") + )); + } +} diff --git a/linux/crates/test-support/src/conformance/cases/mod.rs b/linux/crates/test-support/src/conformance/cases/mod.rs index 376b256d8f..0d2964b28f 100644 --- a/linux/crates/test-support/src/conformance/cases/mod.rs +++ b/linux/crates/test-support/src/conformance/cases/mod.rs @@ -4,6 +4,7 @@ pub mod cancellation; pub mod close; pub mod commit_resolution; +pub mod ddl; pub mod deadlines; pub mod liveness; pub mod metadata; diff --git a/linux/crates/test-support/src/conformance/ddl_conformance_engine.rs b/linux/crates/test-support/src/conformance/ddl_conformance_engine.rs new file mode 100644 index 0000000000..0ac5ae3476 --- /dev/null +++ b/linux/crates/test-support/src/conformance/ddl_conformance_engine.rs @@ -0,0 +1,30 @@ +use tablepro_core::TableRef; +use tablepro_core::ddl::DraftColumn; +use tablepro_core::dialect::SqlDialect; + +use crate::conformance::ConformanceEngine; + +/// What the DDL suite needs on top of the connection suite: the +/// engine's spelling of the three column types its tables use. +pub trait ConformanceDdlEngine: ConformanceEngine { + fn integer_type(&self) -> &'static str; + + fn text_type(&self) -> &'static str; + + /// A type that takes `current_timestamp_expression` as its default. + fn timestamp_type(&self) -> &'static str; + + fn current_timestamp_expression(&self) -> &'static str; + + /// CREATE TABLE for an engine whose DDL dialect does not create + /// tables, keyed on `id`. Only read when `DdlCapabilities::create_table` + /// is off. + fn create_table_sql( + &self, + _dialect: &dyn SqlDialect, + _table: &TableRef, + _columns: &[DraftColumn], + ) -> Option { + None + } +} diff --git a/linux/crates/test-support/src/conformance/ddl_suite_macro.rs b/linux/crates/test-support/src/conformance/ddl_suite_macro.rs new file mode 100644 index 0000000000..6b48621987 --- /dev/null +++ b/linux/crates/test-support/src/conformance/ddl_suite_macro.rs @@ -0,0 +1,30 @@ +/// Emit the DDL conformance cases for one engine adapter, which +/// implements `ConformanceDdlEngine`. `docker: true` marks every case +/// `#[ignore = "requires docker"]`. +/// +/// Each case plans through the connection's own `DdlDialect` and runs +/// the plan with `write_batch`, so what it checks is the SQL the +/// structure tab would send. +#[macro_export] +macro_rules! ddl_conformance_suite { + (engine: $engine:ty, docker: $docker:tt $(,)?) => { + $crate::__conformance_case! { + $docker, + $engine as ConformanceEngine, + ddl_plans_apply_and_read_back, + $crate::conformance::cases::ddl::ddl_plans_apply_and_read_back::<$engine> + } + $crate::__conformance_case! { + $docker, + $engine as ConformanceEngine, + failed_ddl_batch_outcome_matches_ddl_atomicity, + $crate::conformance::cases::ddl::failed_ddl_batch_outcome_matches_ddl_atomicity::<$engine> + } + $crate::__conformance_case! { + $docker, + $engine as ConformanceEngine, + create_table_script_recreates_the_table, + $crate::conformance::cases::ddl::create_table_script_recreates_the_table::<$engine> + } + }; +} diff --git a/linux/crates/test-support/src/conformance/mod.rs b/linux/crates/test-support/src/conformance/mod.rs index 19d315bff9..545bed67cf 100644 --- a/linux/crates/test-support/src/conformance/mod.rs +++ b/linux/crates/test-support/src/conformance/mod.rs @@ -1,9 +1,9 @@ //! The suites every driver runs against a real server. //! //! A driver's integration target implements an engine adapter and -//! invokes `connection_conformance_suite!` and `tls_conformance_suite!` -//! with flags for what the engine supports. A flag that is off emits no -//! test. The flags with a capability behind them (`endpoint`, +//! invokes `connection_conformance_suite!`, `ddl_conformance_suite!` and +//! `tls_conformance_suite!` with flags for what the engine supports. A +//! flag that is off emits no test. The flags with a capability behind them (`endpoint`, //! `editor_transactions` and the TLS `client_identity`) are checked //! against the driver's capability table, so those cases cannot be //! skipped by declaring less. `pool`, `unsupported_types` and @@ -18,6 +18,8 @@ mod conformance_engine; mod conformance_failure; mod connection_suite_flags; mod connection_suite_macro; +mod ddl_conformance_engine; +mod ddl_suite_macro; mod editor_transaction_engine; #[cfg(test)] mod fake_engine; @@ -37,6 +39,7 @@ pub use client_identity_engine::ClientIdentityEngine; pub use conformance_engine::ConformanceEngine; pub use conformance_failure::ConformanceFailure; pub use connection_suite_flags::ConnectionSuiteFlags; +pub use ddl_conformance_engine::ConformanceDdlEngine; pub use editor_transaction_engine::EditorTransactionEngine; pub use network_conformance_engine::NetworkConformanceEngine; pub use pooled_conformance_engine::PooledConformanceEngine; diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 11581bdbfc..4ae48d3fc4 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -26,6 +26,7 @@ crates/app/src/services/from_session.rs crates/app/src/services/history_availability.rs crates/app/src/services/history_service.rs crates/app/src/services/mod.rs +crates/app/src/services/pending_structure.rs crates/app/src/services/secret_labels.rs crates/app/src/services/secret_save_report.rs crates/app/src/services/session_context.rs @@ -57,6 +58,7 @@ crates/app/src/ui/app/import.rs crates/app/src/ui/app/mod.rs crates/app/src/ui/app/ran_statements.rs crates/app/src/ui/app/row_ops.rs +crates/app/src/ui/app/sidebar_section_header.rs crates/app/src/ui/app/status_pages.rs crates/app/src/ui/app/structure.rs crates/app/src/ui/app/teardown_guard.rs @@ -134,6 +136,7 @@ crates/app/src/ui/sidebar_row.rs crates/app/src/ui/ssh_inputs.rs crates/app/src/ui/ssh_section.rs crates/app/src/ui/structure_tab/columns.rs +crates/app/src/ui/structure_tab/ddl_text.rs crates/app/src/ui/structure_tab/fks.rs crates/app/src/ui/structure_tab/imp.rs crates/app/src/ui/structure_tab/indexes.rs From bb21852c06bad14cfda69040ba6e85e1e2406f61 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 23:15:28 +0700 Subject: [PATCH 111/178] docs(linux): record DDL plans in ADR 0010 and the driver guide --- linux/ARCHITECTURE.md | 4 +-- linux/docs/adding-drivers.md | 18 +++++++++- .../0010-ddl-plans-and-table-definitions.md | 35 +++++++++++++++++++ linux/docs/decisions/README.md | 1 + linux/docs/testing.md | 19 ++++++++++ 5 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 linux/docs/decisions/0010-ddl-plans-and-table-definitions.md diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index b3a8a734cf..84e3d6e299 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -223,11 +223,11 @@ Each window owns two registries holding the in-flight edit state for its tabs, k | Registry | Handle | What it tracks | Materialised by | |---|---|---|---| | `ChangeTrackerRegistry` | `TabTrackerHandle` | Row-level INSERT / UPDATE / DELETE for Table tabs | `TabTrackerHandle::change_set` → `TrackedChanges` | -| `StructureTrackerRegistry` | `StructureTrackerHandle` | Column / index / FK / table-rename DDL for Structure tabs | `sql_ddl::materialize_ops` → `Vec` | +| `StructureTrackerRegistry` | `StructureTrackerHandle` | The table's definition, the user's `TableDraft` and the `TableChanges` between them, for Structure tabs | `PendingStructure::plan` through the connection's `DdlDialect` → `DdlPlan` | A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to rebind, the draft rows the list store gains, loses or updates, and the new pending count. The tab applies it to its `gio::ListStore` after the call returns. Every pending change carries a `Touch` from the tracker's counter. An `UndoOp` holds the pending state on both sides of a step, touch included, so undo after A, B, C shows B, not the fetched value. `change_set` emits one change per row in touch order, and `plan_save` keeps that order: a row deleted and replaced by a draft with the same unique value saves, because its DELETE runs first. `App::dirty_tabs` reads both registries for the close prompts, the teardown guard and the `•` title prefix. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. -The Structure tab is **snapshot + diff**, not per-op log. `original_*` snapshots capture the load-time schema; the diff against the live model produces ops via `sql_ddl::diff_to_ops`. Discard restores the snapshot. There is no per-op undo: Discard is the only restore point. The tracker just caches the most recent diff so out-of-band callers (close prompt, save dispatcher) read the same op list without re-deriving. +The Structure tab is **definition + draft**, not a per-change log. The `TableDefinition` one read returned is what the tab plans against; `diff_table` turns the draft into `TableChanges`, and the engine's `DdlDialect` plans them into statements in its own order. Discard copies the definition back over the draft, and is the only restore point. The tracker holds the latest `PendingStructure`, so the close prompt and the save dispatcher read the same change without the tab, and the save plans it again with the dialect of the connection that runs it ([ADR 0010](docs/decisions/0010-ddl-plans-and-table-definitions.md)). ## Persistence diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index 62080fdb89..73532b77e1 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -10,7 +10,7 @@ The work, in order: 4. Implement `Connection` on `OwnedCalls`. 5. Write saves through a `WriteLedger`. 6. Implement `EditorSession`. -7. Implement `SqlDialect`, and `DdlDialect` where the app edits the engine's structure. +7. Implement `SqlDialect`, and `DdlDialect` where the app edits the engine's structure (section 7). 8. Register the driver. 9. Add a container fixture, the conformance suites and the engine's own tests. 10. Write the engine's page in `docs/drivers/`. @@ -307,6 +307,22 @@ A run streams `ResultEvent`s into a `ResultSink`: `Connection::dialect()` returns the engine's `SqlDialect`, built from `ServerInfo` at connect. Every builder in `tablepro-core` (pages, filters, row edits, exports) goes through it: quoting, placeholders, literals, the page window, keyset comparison, the engine's row address and type parsing. A dialect never reads session state. `SqlDialect::ddl()` returns the engine's `DdlDialect`, or `None` where the app does not edit the engine's structure. Never match on a driver id in core; add a dialect method. +### Structure changes + +`DdlDialect` lives in the driver's `src/ddl.rs`, implemented on the dialect type itself, so `ddl()` returns `Some(self)`. It plans every structure change the Structure tab makes, against the `TableDefinition` that `fetch_table_definition` returned: + +| Method | What it does | +|---|---| +| `capabilities` | The table-level `DdlCapabilities`. The tab offers only what is on. | +| `column_capabilities` | A `Permission` per change to one column. Start from `ColumnCapabilities::standard`, which refuses a restatement that would drop a generated expression or an unmodeled attribute, a default on a numbered column, a drop of a column another table's foreign key points at, and any primary-key change while a foreign key points at the key. Add the engine's own refusals on top, each with its `RefusalReason`. | +| `type_suggestions`, `default_column_type`, `referential_actions` | What the tab's type menu, new columns and foreign-key dialog offer. Every suggestion has to parse through `parse_type`. | +| `plan_create_table(&TableDraft)` | Starts with `draft.check_create(self)?`. Foreign keys go inside CREATE TABLE. | +| `plan_alter_table(&TableDefinition, &TableChanges)` | Starts with `changes.check(self, original)?`, then writes the changes in the order the engine needs: one combined ALTER TABLE where the engine takes one, renames first, and every statement after a table rename against the new name. A table rename keeps the table in its schema. | +| `plan_drop_table` | The default writes `DROP TABLE [IF EXISTS]
[CASCADE]`. | +| `create_table_script` | The server's own CREATE statement where the engine keeps one; the default writes it from the definition. | + +Plans quote every name through the dialect, splice a catalogue type unchanged and a typed one only after `parse_type` accepts it, and write a literal default through `SqlDialect::literal`. `tablepro_core::ddl::ddl_sql` has the pieces most engines share. The golden tests for each plan sit in `src/ddl.rs`, and the driver's integration target runs `ddl_conformance_suite!` (see [testing.md](testing.md#ddl-conformance)). + ## 8. Registration Add the crate to `crates/app/Cargo.toml` and register it in `build_registry` in `crates/app/src/lib.rs`: diff --git a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md new file mode 100644 index 0000000000..88008dbb09 --- /dev/null +++ b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md @@ -0,0 +1,35 @@ +# 0010: DDL plans against table definitions + +- **Status**: Accepted +- **Date**: 2026-10-13 + +## Context + +The structure tab wrote its SQL through one builder in `tablepro-core`, keyed by driver id. It quoted names itself, matched on `"mysql"` or `"mssql"` to pick a spelling, and turned each edit into one statement per operation. That could not express MySQL's single combined ALTER, SQL Server's drop of a named default before a type change, ClickHouse's `RENAME TABLE`, or SQLite's limits, and the UI offered changes the engine then refused at Save. The 2026-09-14 audit traced wrong and refused DDL to it (F-021, F-045, F-050, F-054, F-117). + +## Decision + +Each engine plans structure changes through its own `DdlDialect`, reached from `SqlDialect::ddl()`, against the `TableDefinition` one `fetch_table_definition` call returned, and the plan is what the preview shows and what the save runs. + +## Rationale + +**One change, the engine's order.** The tab edits a `TableDraft`. `diff_table` turns it into `TableChanges`: the table rename, the columns dropped, renamed, altered and added, the primary-key move, and the indexes and foreign keys dropped and added. `plan_alter_table` writes them in the order the engine needs: one combined ALTER TABLE on PostgreSQL, the foreign keys around one ALTER on MySQL, separate statements with the named default dropped first on SQL Server, native ALTER TABLE only on SQLite, one ALTER per change on ClickHouse. A `DdlPlan` carries the statements and the `ForeignKeyMode`, and becomes the `WriteBatch`. + +**Refused before Save.** `DdlCapabilities` and `ColumnCapabilities` answer which changes the engine takes, each refusal with a `RefusalReason` the tab shows beside the control it turns off. A primary key another table's foreign key points at cannot move on any engine, and a column such a key references cannot be dropped. Every plan runs the same checks first, so a caller that skips the UI gets `BuildDdlError::Refused` or `Unsupported` rather than SQL the server rejects. + +**Nothing spliced unchecked.** A catalogue type is spliced as the server gave it. A typed one is spliced only after `SqlDialect::parse_type` accepts it, a typed default only after `parse_expression` does, and a literal default goes through `SqlDialect::literal`. Every name goes through the dialect's quoting. + +**The connection that runs it.** The save plans again inside the session operation, with the dialect of the lease that writes it, so a reconnect to another server version between edit and Save cannot send SQL spelled for the old one. + +**Tested on the server.** Each driver's `src/ddl.rs` holds golden tests for its plans, and `ddl_conformance_suite!` runs the plans against every engine: each change applies and reads back as drafted, a failed batch ends as `ddl_writes` says, and `create_table_script` recreates the table. + +## Consequences + +- An engine's DDL is one file in its driver crate, and core has no branch on a driver id. +- The structure tab's controls follow the capabilities, so a change the engine cannot make is never offered. +- Primary-key identity on PostgreSQL, SQLite table rebuilds and the full restatement of MySQL and SQL Server column attributes are plans of their own, added to the same dialects later. Until then their capabilities are off. + +## Alternatives considered + +- **sea-query.** It covers only MySQL, PostgreSQL and SQLite in its open-source backends, and cannot express MySQL's MODIFY, SQLite rebuilds or SQL Server default constraints. +- **One method per operation on `DdlDialect`.** Per-operation statements cannot be merged into MySQL's single ALTER or reordered around SQL Server's default constraints, which is why the plan takes the whole change. diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index 6b2d4bb7d4..d3b6e9c058 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -47,6 +47,7 @@ ADRs are short on purpose. If yours is more than a page, you are probably arguin | [0006](0006-meson-cargo-build.md) | Meson drives Cargo | Accepted | Meson owns install, data files and the build-time config; Cargo stays the Rust compiler. | | [0007](0007-gsettings-preferences.md) | Preferences and window geometry in GSettings | Accepted | A schema with ranges and enums replaces preferences.json and window.json; keyed data stays in files. | | [0009](0009-crypto-provider-and-sources.md) | One crypto provider, upstream sources and dependency bans | Accepted | aws-lc-rs only; deny.toml enforces sources, licenses, bans and advisory ignores. | +| [0010](0010-ddl-plans-and-table-definitions.md) | DDL plans against table definitions | Accepted | Each engine plans structure changes through its `DdlDialect` against the table's definition; the UI offers only what the plan accepts. | | [0013](0013-openssh-client-transport.md) | SSH through the system OpenSSH client | Accepted | ControlMaster per connection, Unix-socket forwards, prompts through an askpass helper. | | [0015](0015-gnome-50-platform-floor.md) | GNOME 50 platform floor | Accepted | GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88; AdwShortcutsDialog replaces the shortcuts window. | | [0016](0016-driver-contract-v2.md) | Driver contract v2 | Accepted | Owned calls with engine stops, phase-aware write outcomes, server-side row limits; PostgreSQL on tokio-postgres; no forks. | diff --git a/linux/docs/testing.md b/linux/docs/testing.md index dd4c37ba65..f8aa25b868 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -128,6 +128,25 @@ A flag that is off emits no test. `suite_flags_match_driver_capabilities` is alw Each case starts its own engine, then has two minutes (`CASE_LIMIT`) to finish. A driver that ignores a deadline or a cancel fails the case with its name instead of holding the test run. +### DDL conformance + +`ddl_conformance_suite!` runs the structure tab's own plans against the server. The adapter implements `ConformanceDdlEngine` on top of `ConformanceEngine`: the engine's integer, text and timestamp types, its current-timestamp expression, and, for an engine whose `DdlDialect` creates no tables (ClickHouse), a CREATE TABLE of its own. + +```rust +tablepro_test_support::ddl_conformance_suite! { + engine: PgEngine, + docker: true, +} +``` + +Every case plans through `connection.dialect().ddl()` and runs the plan with `write_batch`: + +- `ddl_plans_apply_and_read_back` creates a table with a quoted literal default and an expression default, then adds, renames and drops a column, changes its nullability and default, adds an index and a foreign key, and drops the tables. After each step `fetch_table_definition` has to match the draft. A step the dialect refuses has to be refused by its plan as well, with a reason, so no step is skipped without a check. Once the foreign key exists, the referenced primary key has to read as held by it. +- `failed_ddl_batch_outcome_matches_ddl_atomicity` sends two plans whose second statement the server rejects, and checks the outcome against `ddl_writes`: `RolledBack` and nothing applied, or `PartiallyApplied` and the first plan applied. +- `create_table_script_recreates_the_table` drops a table and runs its `create_table_script`, which has to bring back the same column definitions and index keys. + +All six targets run it; SQLite passes `docker: false`, so its cases run in the unit suite. + ### TLS conformance Every network driver also runs `tls_conformance_suite!` against a TLS-only server built from `TestPki`: From 7267319cb127458d08f2e8d42ad269d47f7d7a33 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 23:23:19 +0700 Subject: [PATCH 112/178] fix(linux-editor): read only catalog names, skip FROM in calls A failed catalog read is asked again on every completion, so a CTE or subquery name the catalog does not hold is never read. --- .../src/ui/editor/completion/catalog_read.rs | 130 ++++++++--- .../ui/editor/completion/completion_scope.rs | 201 ++++++++++++++---- .../app/src/ui/editor/completion/tests.rs | 167 ++++++++++++--- 3 files changed, 400 insertions(+), 98 deletions(-) diff --git a/linux/crates/app/src/ui/editor/completion/catalog_read.rs b/linux/crates/app/src/ui/editor/completion/catalog_read.rs index 4c02ebaa8c..1c2eede515 100644 --- a/linux/crates/app/src/ui/editor/completion/catalog_read.rs +++ b/linux/crates/app/src/ui/editor/completion/catalog_read.rs @@ -30,18 +30,30 @@ pub async fn read(session: Arc, scope: CompletionScope) -> CatalogAnswe let table = relation_named(&defaults, &name); CatalogAnswer { schemas: None, - objects: Some(listed(session.objects(ObjectScope::Schema(name)).await)), + objects: schema_objects(&session, &name).await, columns: columns_of(&session, table.into_iter().collect()).await, } } + CompletionScope::Qualified { qualifier, name } => { + let table = TableRef::new(Some(qualifier), name.clone()); + match known_table(&session, &defaults, &table).await { + Some(table) => CatalogAnswer { + columns: columns_of(&session, vec![table]).await, + ..CatalogAnswer::default() + }, + None => CatalogAnswer { + objects: schema_objects(&session, &name).await, + ..CatalogAnswer::default() + }, + } + } CompletionScope::Columns { tables } => CatalogAnswer { - schemas: None, - objects: None, - columns: columns_of(&session, resolved(&tables, &defaults)).await, + columns: columns_of(&session, known_tables(&session, &defaults, &tables).await).await, + ..CatalogAnswer::default() }, CompletionScope::General { tables_in_statement } => CatalogAnswer { schemas: None, - columns: columns_of(&session, resolved(&tables_in_statement, &defaults)).await, + columns: columns_of(&session, known_tables(&session, &defaults, &tables_in_statement).await).await, objects: Some(defaults), }, } @@ -62,28 +74,65 @@ fn listed(read: Result, Arc>) -> Arc<[T]> { }) } -fn resolved(tables: &[TableRef], defaults: &[ObjectInfo]) -> Vec { - tables.iter().map(|table| resolve(table, defaults)).collect() +/// The objects of the schema called `name`, or `None` when the catalog +/// has no such schema. +async fn schema_objects(session: &Session, name: &str) -> Option> { + let schemas = listed(session.schemas().await); + let schema = schema_named(&schemas, name)?; + Some(listed(session.objects(ObjectScope::Schema(schema.name.clone())).await)) +} + +/// The catalog's own names for the tables among `tables` it holds. A +/// name it does not hold, such as a CTE's, is left out: reading it +/// could only fail, and a failed read is asked again every time. +async fn known_tables(session: &Session, defaults: &[ObjectInfo], tables: &[TableRef]) -> Vec { + let mut known = Vec::new(); + for table in tables { + if let Some(table) = known_table(session, defaults, table).await + && !known.contains(&table) + { + known.push(table); + } + } + known } -/// The catalog's own name for a table the statement names without a -/// schema, so the read is the one the table's other views share. -fn resolve(table: &TableRef, defaults: &[ObjectInfo]) -> TableRef { - if table.schema.is_some() { - return table.clone(); +/// The catalog's own name for `table`, so the read is the one the +/// table's other views share. +async fn known_table(session: &Session, defaults: &[ObjectInfo], table: &TableRef) -> Option { + let Some(schema) = &table.schema else { + return relation_named(defaults, &table.name); + }; + let schemas = listed(session.schemas().await); + let schema = schema_named(&schemas, schema)?; + if schema.is_default { + return relation_named(defaults, &table.name); } - relation_named(defaults, &table.name).unwrap_or_else(|| table.clone()) + let objects = listed(session.objects(ObjectScope::Schema(schema.name.clone())).await); + relation_named(&objects, &table.name) } -/// The default schema's relation called `name`, as written or, failing -/// that, ignoring case the way an unquoted name is read. -fn relation_named(defaults: &[ObjectInfo], name: &str) -> Option { - let relations = || defaults.iter().filter(|object| object.kind.is_browsable()); +fn schema_named<'a>(schemas: &'a [SchemaInfo], name: &str) -> Option<&'a SchemaInfo> { + named(schemas, name, |schema| Some(schema.name.as_str())) +} + +fn relation_named(objects: &[ObjectInfo], name: &str) -> Option { + named(objects, name, |object| { + object.kind.is_browsable().then_some(object.name.as_str()) + }) + .map(|object| TableRef::new(object.schema.clone(), object.name.clone())) +} + +/// The item called `name`, as written or, failing that, ignoring case +/// the way an unquoted name is read. `name_of` gives `None` for an item +/// that does not count. +fn named<'a, T>(items: &'a [T], name: &str, name_of: impl Fn(&T) -> Option<&str>) -> Option<&'a T> { let folded = name.to_lowercase(); - relations() - .find(|object| object.name == name) - .or_else(|| relations().find(|object| object.name.to_lowercase() == folded)) - .map(|object| TableRef::new(object.schema.clone(), object.name.clone())) + items.iter().find(|item| name_of(item) == Some(name)).or_else(|| { + items + .iter() + .find(|item| name_of(item).is_some_and(|own| own.to_lowercase() == folded)) + }) } #[cfg(test)] @@ -105,19 +154,14 @@ mod tests { let defaults = [object("users", ObjectKind::Table), object("Orders", ObjectKind::Table)]; assert_eq!( - resolve(&TableRef::unqualified("users"), &defaults), - TableRef::new(Some("public".to_owned()), "users") + relation_named(&defaults, "users"), + Some(TableRef::new(Some("public".to_owned()), "users")) ); assert_eq!( - resolve(&TableRef::unqualified("orders"), &defaults), - TableRef::new(Some("public".to_owned()), "Orders") + relation_named(&defaults, "orders"), + Some(TableRef::new(Some("public".to_owned()), "Orders")) ); - assert_eq!( - resolve(&TableRef::unqualified("missing"), &defaults), - TableRef::unqualified("missing") - ); - let qualified = TableRef::new(Some("sales".to_owned()), "users"); - assert_eq!(resolve(&qualified, &defaults), qualified); + assert_eq!(relation_named(&defaults, "missing"), None); } #[test] @@ -126,4 +170,28 @@ mod tests { assert_eq!(relation_named(&defaults, "refresh"), None); } + + #[test] + fn a_schema_is_matched_as_written_then_ignoring_case() { + let schema = |name: &str| SchemaInfo { + name: name.to_owned(), + is_default: false, + is_system: false, + }; + let schemas = [schema("sales"), schema("Audit"), schema("audit")]; + + assert_eq!( + schema_named(&schemas, "SALES").map(|schema| schema.name.as_str()), + Some("sales") + ); + assert_eq!( + schema_named(&schemas, "Audit").map(|schema| schema.name.as_str()), + Some("Audit") + ); + assert_eq!( + schema_named(&schemas, "audit").map(|schema| schema.name.as_str()), + Some("audit") + ); + assert!(schema_named(&schemas, "hr").is_none()); + } } diff --git a/linux/crates/app/src/ui/editor/completion/completion_scope.rs b/linux/crates/app/src/ui/editor/completion/completion_scope.rs index 3719b35d66..944252f483 100644 --- a/linux/crates/app/src/ui/editor/completion/completion_scope.rs +++ b/linux/crates/app/src/ui/editor/completion/completion_scope.rs @@ -11,6 +11,10 @@ pub enum CompletionScope { Objects { schema: Option }, /// A column of one of these tables. Columns { tables: Vec }, + /// After `a.b.`: the columns of table `b` in schema `a`, or the + /// objects of schema `b` when `a` names the database, as SQL + /// Server's `db.dbo.` does. + Qualified { qualifier: String, name: String }, /// Anything: keywords, objects, and the columns of the tables the /// statement already names. General { tables_in_statement: Vec }, @@ -19,6 +23,10 @@ pub enum CompletionScope { /// The words after which a table name comes. const RELATION_WORDS: [&str; 5] = ["FROM", "JOIN", "INTO", "UPDATE", "TABLE"]; +/// The first word of a parenthesised query, inside which a FROM names +/// tables again. +const QUERY_WORDS: [&str; 5] = ["SELECT", "WITH", "INSERT", "UPDATE", "DELETE"]; + /// Words that are never an alias, beyond the completed keywords. const CLAUSE_WORDS: [&str; 12] = [ "FETCH", @@ -38,13 +46,15 @@ const CLAUSE_WORDS: [&str; 12] = [ /// What to complete at byte `cursor` of `text`. /// /// `None` inside a string, a comment, a quoted name or a dollar-quoted -/// body: what is typed there is not SQL the editor can read. +/// body, where what is typed is not SQL the editor can read, and inside +/// a number or after a dot that follows no name, where nothing has a +/// name to complete. pub fn analyze(text: &str, cursor: usize, grammar: SqlGrammar, settings: LexicalSettings) -> Option { let cursor = floor_char_boundary(text, cursor); let plan = ScriptPlan::build(text, grammar, settings); if plan .tokens(cursor.saturating_sub(1)..cursor + 1) - .any(|token| encloses(&token, cursor)) + .any(|token| encloses(&token, text, cursor)) { return None; } @@ -59,58 +69,108 @@ pub fn analyze(text: &str, cursor: usize, grammar: SqlGrammar, settings: Lexical .filter_map(|token| Lexeme::new(text, token)) .collect(); let tables = TableMap::collect(&lexemes, grammar); - let mut before: Vec<&Lexeme<'_>> = lexemes.iter().take_while(|lexeme| lexeme.end <= cursor).collect(); + let mut before = lexemes.iter().take_while(|lexeme| lexeme.end <= cursor).count(); if before - .last() + .checked_sub(1) + .and_then(|last| lexemes.get(last)) .is_some_and(|last| last.kind == ScriptTokenKind::Word && last.end == cursor) { - before.pop(); + before -= 1; } - Some(scope_after(&before, &tables, grammar)) + scope_after(lexemes.get(..before).unwrap_or_default(), &tables, grammar) } -fn scope_after(before: &[&Lexeme<'_>], tables: &TableMap, grammar: SqlGrammar) -> CompletionScope { - let general = || CompletionScope::General { - tables_in_statement: tables.in_order(), - }; +fn scope_after(before: &[Lexeme<'_>], tables: &TableMap, grammar: SqlGrammar) -> Option { let Some((last, rest)) = before.split_last() else { - return general(); + return Some(tables.general()); }; if last.is_punct(".") { - return after_dot(rest, tables).unwrap_or_else(general); + return after_dot(rest, tables); } - if RELATION_WORDS.iter().any(|word| last.is_word(word)) || (last.is_punct(",") && in_from_list(rest, grammar)) { - return CompletionScope::Objects { schema: None }; + let relation = relation_word(before, rest.len()).is_some(); + if relation || (last.is_punct(",") && in_from_list(rest, grammar)) { + return Some(CompletionScope::Objects { schema: None }); } - general() + Some(tables.general()) } /// `name.` names a table's columns when the statement knows the name, -/// and a schema's objects otherwise; `schema.table.` is always columns. -fn after_dot(rest: &[&Lexeme<'_>], tables: &TableMap) -> Option { +/// and a schema's objects otherwise; `a.b.` may be either, and `a.b.c.` +/// is table `b.c`. `None` when no name comes before the dot, as after a +/// string or a bracket. +fn after_dot(rest: &[Lexeme<'_>], tables: &TableMap) -> Option { let (name, rest) = rest.split_last()?; let name = name.identifier()?; - if let [.., schema, dot] = rest - && dot.is_punct(".") - && let Some(schema) = schema.identifier() - { - return Some(CompletionScope::Columns { + Some(match name_before_dot(rest) { + Some((schema, rest)) if name_before_dot(rest).is_some() => CompletionScope::Columns { tables: vec![TableRef::new(Some(schema), name)], - }); - } - Some(match tables.lookup(&name) { - Some(table) => CompletionScope::Columns { - tables: vec![table.clone()], }, - None => CompletionScope::Objects { schema: Some(name) }, + Some((qualifier, _)) => CompletionScope::Qualified { qualifier, name }, + None => match tables.lookup(&name) { + Some(table) => CompletionScope::Columns { + tables: vec![table.clone()], + }, + None => CompletionScope::Objects { schema: Some(name) }, + }, }) } +/// The name before the dot that ends `lexemes`, and what precedes it. +fn name_before_dot<'s, 'a>(lexemes: &'s [Lexeme<'a>]) -> Option<(String, &'s [Lexeme<'a>])> { + let [rest @ .., name, dot] = lexemes else { + return None; + }; + if !dot.is_punct(".") { + return None; + } + Some((name.identifier()?, rest)) +} + +/// The relation word at `index`, when a table name follows it. +fn relation_word(lexemes: &[Lexeme<'_>], index: usize) -> Option<&'static str> { + let lexeme = lexemes.get(index)?; + let word = RELATION_WORDS.into_iter().find(|word| lexeme.is_word(word))?; + (word != "FROM" || from_names_tables(lexemes, index)).then_some(word) +} + +/// Whether the FROM at `index` starts a table list, rather than ending +/// `IS DISTINCT FROM` or sitting in a call such as +/// `EXTRACT(YEAR FROM created_at)`. +fn from_names_tables(lexemes: &[Lexeme<'_>], index: usize) -> bool { + let follows_distinct = index + .checked_sub(1) + .and_then(|before| lexemes.get(before)) + .is_some_and(|before| before.is_word("DISTINCT")); + if follows_distinct { + return false; + } + match enclosing_group(lexemes, index) { + None => true, + Some(open) => lexemes + .get(open + 1) + .is_some_and(|first| QUERY_WORDS.iter().any(|word| first.is_word(word))), + } +} + +/// The index of the `(` that opens the innermost group around `index`. +fn enclosing_group(lexemes: &[Lexeme<'_>], index: usize) -> Option { + let mut depth = 0_usize; + for (at, lexeme) in lexemes.get(..index)?.iter().enumerate().rev() { + match lexeme.kind { + ScriptTokenKind::CloseParen => depth += 1, + ScriptTokenKind::OpenParen if depth == 0 => return Some(at), + ScriptTokenKind::OpenParen => depth -= 1, + _ => {} + } + } + None +} + /// Whether a comma continues the table list of a FROM clause. -fn in_from_list(rest: &[&Lexeme<'_>], grammar: SqlGrammar) -> bool { - for lexeme in rest.iter().rev() { +fn in_from_list(rest: &[Lexeme<'_>], grammar: SqlGrammar) -> bool { + for (index, lexeme) in rest.iter().enumerate().rev() { if lexeme.is_word("FROM") { - return true; + return from_names_tables(rest, index); } let part_of_list = lexeme.is_punct(",") || lexeme.is_punct(".") @@ -135,8 +195,8 @@ impl TableMap { /// subquery, so a cursor in the subquery sees its tables too. fn collect(lexemes: &[Lexeme<'_>], grammar: SqlGrammar) -> Self { let mut map = Self::default(); - for (index, lexeme) in lexemes.iter().enumerate() { - let Some(keyword) = RELATION_WORDS.iter().find(|word| lexeme.is_word(word)) else { + for index in 0..lexemes.len() { + let Some(keyword) = relation_word(lexemes, index) else { continue; }; let mut at = index + 1; @@ -155,7 +215,7 @@ impl TableMap { // A set-returning call in FROM is not a table, and // its arguments are no alias. let called = lexemes.get(at).is_some_and(Lexeme::opens_group); - if called && matches!(*keyword, "FROM" | "JOIN") { + if called && matches!(keyword, "FROM" | "JOIN") { at = after_group(lexemes, at); None } else { @@ -167,7 +227,7 @@ impl TableMap { if let Some(table) = table { map.insert(table, alias); } - if *keyword == "FROM" && lexemes.get(at).is_some_and(|next| next.is_punct(",")) { + if keyword == "FROM" && lexemes.get(at).is_some_and(|next| next.is_punct(",")) { at += 1; continue; } @@ -195,6 +255,12 @@ impl TableMap { .map(|(_, table)| table) } + fn general(&self) -> CompletionScope { + CompletionScope::General { + tables_in_statement: self.in_order(), + } + } + fn in_order(&self) -> Vec { let mut tables: Vec = Vec::new(); for (_, table) in &self.entries { @@ -272,16 +338,24 @@ fn statement_around(plan: &ScriptPlan, cursor: usize) -> Option> { (!ended).then_some(statement.range.start..cursor) } -/// Whether the cursor sits inside a token whose text is not SQL. A -/// line comment and an unclosed construct run to their end, so the end -/// counts as inside; a closed quote ends before its last character. -fn encloses(token: &ScriptToken, cursor: usize) -> bool { +/// Whether the cursor sits inside a token nothing completes in. A line +/// comment and an unclosed construct run to their end, so the end +/// counts as inside; a closed quote ends before its last character; a +/// number holds the cursor up to and just after its decimal point. +fn encloses(token: &ScriptToken, text: &str, cursor: usize) -> bool { let inside = token.range.start < cursor && cursor < token.range.end; let at_open_end = token.range.start < cursor && cursor == token.range.end; match token.kind { ScriptTokenKind::LineComment | ScriptTokenKind::Unterminated | ScriptTokenKind::Directive => { inside || at_open_end } + ScriptTokenKind::Number => { + inside + || (at_open_end + && text + .get(token.range.clone()) + .is_some_and(|number| number.ends_with('.'))) + } ScriptTokenKind::StringLiteral | ScriptTokenKind::QuotedIdentifier | ScriptTokenKind::DollarQuoted @@ -404,10 +478,38 @@ mod tests { columns(vec![table(Some("public"), "users")]) ); assert_eq!(scope("SELECT users.| FROM users"), columns(vec![table(None, "users")])); + assert_eq!(scope("SELECT t1.| FROM t1"), columns(vec![table(None, "t1")])); + assert_eq!( + scope_in(SqlGrammar::MsSql, "SELECT shop.dbo.users.| FROM x"), + columns(vec![table(Some("dbo"), "users")]) + ); + } + + #[test] + fn a_two_part_prefix_is_a_table_or_a_databases_schema() { assert_eq!( scope("SELECT public.users.| FROM x"), - columns(vec![table(Some("public"), "users")]) + Some(CompletionScope::Qualified { + qualifier: "public".to_owned(), + name: "users".to_owned() + }) ); + assert_eq!( + scope_in(SqlGrammar::MsSql, "SELECT * FROM shop.dbo.|"), + Some(CompletionScope::Qualified { + qualifier: "shop".to_owned(), + name: "dbo".to_owned() + }) + ); + } + + #[test] + fn a_dot_after_no_name_proposes_nothing() { + assert_eq!(scope("SELECT * FROM items WHERE price > 0.|"), None); + assert_eq!(scope("SELECT * FROM items WHERE price > 0.|5"), None); + assert_eq!(scope("SELECT * FROM items WHERE price > .|"), None); + assert_eq!(scope("SELECT 'a'.|"), None); + assert_eq!(scope("SELECT (1).|"), None); } #[test] @@ -503,6 +605,25 @@ mod tests { ); } + #[test] + fn a_from_after_distinct_or_inside_a_call_names_no_table() { + let orders = || general(vec![table(None, "orders")]); + assert_eq!(scope("SELECT * FROM orders WHERE a IS DISTINCT FROM |"), orders()); + assert_eq!(scope("SELECT * FROM orders WHERE a IS NOT DISTINCT FROM |"), orders()); + assert_eq!(scope("SELECT EXTRACT(YEAR FROM |) FROM orders"), orders()); + assert_eq!(scope("SELECT SUBSTRING(name FROM |) FROM orders"), orders()); + assert_eq!(scope("SELECT TRIM(BOTH ' ' FROM |) FROM orders"), orders()); + assert_eq!(scope("SELECT TRIM(FROM |) FROM orders"), orders()); + assert_eq!( + scope("SELECT EXTRACT(YEAR FROM created_at) FROM orders WHERE |"), + orders(), + "a call's argument was taken for a table" + ); + assert_eq!(scope("SELECT * FROM orders WHERE id IN (SELECT id FROM |"), OBJECTS); + assert_eq!(scope("SELECT EXISTS(SELECT 1 FROM |"), OBJECTS); + assert_eq!(scope("WITH gone AS (DELETE FROM |"), OBJECTS); + } + #[test] fn a_call_or_subquery_in_from_is_not_a_table() { assert_eq!( diff --git a/linux/crates/app/src/ui/editor/completion/tests.rs b/linux/crates/app/src/ui/editor/completion/tests.rs index 2d4826ce2d..2ba6859efd 100644 --- a/linux/crates/app/src/ui/editor/completion/tests.rs +++ b/linux/crates/app/src/ui/editor/completion/tests.rs @@ -5,9 +5,9 @@ use gtk4::prelude::*; use gtk4::subclass::prelude::*; use sourceview5::CompletionColumn; use sourceview5::prelude::*; -use tablepro_core::meta::{ObjectInfo, ObjectKind}; +use tablepro_core::meta::{ObjectInfo, ObjectKind, ObjectScope, SchemaInfo}; use tablepro_core::{ColumnInfo, TableRef}; -use tablepro_test_support::{ConnectionScript, FakeReply}; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; use super::proposal_kind::ProposalKind; use super::sql_completion_provider::{SqlCompletionProvider, column_text, replace_word}; @@ -20,10 +20,24 @@ const WAIT: Duration = Duration::from_secs(10); struct Harness { provider: SqlCompletionProvider, - _session: Option, + session: Option, _runtime: crate::runtime::AppRuntime, } +impl Harness { + fn fetched_columns(&self) -> usize { + self.session + .as_ref() + .map_or(0, |fake| CallCounters::get(&fake.connection.counters().fetch_columns)) + } + + fn listed_objects(&self) -> usize { + self.session + .as_ref() + .map_or(0, |fake| CallCounters::get(&fake.connection.counters().list_objects)) + } +} + fn object(name: &str, kind: ObjectKind) -> ObjectInfo { ObjectInfo { schema: Some("public".to_owned()), @@ -45,16 +59,30 @@ fn column(name: &str, data_type: &str) -> ColumnInfo { } } +fn schema(name: &str, is_default: bool) -> SchemaInfo { + SchemaInfo { + name: name.to_owned(), + is_default, + is_system: false, + } +} + /// A provider on a session whose default schema holds `objects`. fn connected(script: ConnectionScript, objects: Vec) -> Harness { + connected_with(script, Vec::new(), objects) +} + +/// A provider on a session that has `schemas`, and whose default +/// schema holds `objects`. +fn connected_with(script: ConnectionScript, schemas: Vec, objects: Vec) -> Harness { let runtime = test_runtime(); let fake = FakeSession::start(script, [], runtime.tasks()); - fake.session.catalog().commit(Vec::new(), objects); + fake.session.catalog().commit(schemas, objects); let active = ActiveSession::new(); active.replace(fake.session.clone()); Harness { provider: SqlCompletionProvider::new(active, runtime.tasks()), - _session: Some(fake), + session: Some(fake), _runtime: runtime, } } @@ -63,7 +91,7 @@ fn unconnected() -> Harness { let runtime = test_runtime(); Harness { provider: SqlCompletionProvider::new(ActiveSession::new(), runtime.tasks()), - _session: None, + session: None, _runtime: runtime, } } @@ -235,6 +263,7 @@ fn identifier_needing_quotes_is_quoted_by_dialect() { vec![ object("Order Items", ObjectKind::Table), object("users", ObjectKind::Table), + object("to", ObjectKind::Table), ], ); @@ -242,41 +271,125 @@ fn identifier_needing_quotes_is_quoted_by_dialect() { assert_eq!(find(&found, "Order Items").insert_text(""), "\"Order Items\""); assert_eq!(find(&found, "users").insert_text(""), "users"); + assert_eq!( + find(&found, "to").insert_text(""), + "\"to\"", + "a reserved word the editor does not complete went in bare" + ); } #[gtk4::test] -fn nothing_is_proposed_inside_a_string() { +fn a_dot_in_a_number_proposes_nothing() { let harness = connected(ConnectionScript::default(), vec![object("users", ObjectKind::Table)]); - assert!(proposals(&harness, "SELECT 'us|").is_empty()); + assert!(proposals(&harness, "SELECT * FROM users WHERE price > 0.|").is_empty()); } #[gtk4::test] -fn refilter_narrows_the_list_without_reading_again() { - let harness = connected( +fn a_schema_prefix_is_matched_without_case() { + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::ok(vec![ObjectInfo { + schema: Some("sales".to_owned()), + name: "orders".to_owned(), + kind: ObjectKind::Table, + }])); + let harness = connected_with( + script, + vec![schema("public", true), schema("sales", false)], + vec![object("users", ObjectKind::Table)], + ); + + let found = proposals(&harness, "SELECT * FROM Sales.|"); + + assert_eq!(labels(&found), ["orders"]); + let session = &harness.session.as_ref().expect("a session").session; + let listed = session.subscribe_objects(ObjectScope::Schema("sales".to_owned())); + assert_eq!( + listed.borrow().objects.len(), + 1, + "the schema was read under the name as typed" + ); +} + +#[gtk4::test] +fn a_prefix_that_names_no_schema_reads_nothing() { + let harness = connected_with( ConnectionScript::default(), - vec![object("users", ObjectKind::Table), object("orders", ObjectKind::Table)], + vec![schema("public", true)], + vec![object("users", ObjectKind::Table)], ); - let (text, start, word) = word_before("SELECT * FROM |"); - let buffer = gtk4::TextBuffer::new(None); - buffer.set_text(&text); - let provider = harness.provider.clone(); - let model = - finish_on_main(WAIT, async move { provider.proposals(&buffer, start, &word).await }).expect("the proposals"); - assert_eq!(model.n_items(), 2); - harness.provider.refilter("ord", &model); + let listed = harness.listed_objects(); - assert_eq!(model.n_items(), 1); + let found = proposals(&harness, "SELECT * FROM hr.|"); + + assert!(found.is_empty()); + assert_eq!(harness.listed_objects(), listed); +} + +#[gtk4::test] +fn names_the_catalog_does_not_hold_are_not_read() { + let mut script = ConnectionScript::default(); + script + .fetch_columns + .always(FakeReply::ok(vec![column("id", "integer")])); + let harness = connected(script, vec![object("users", ObjectKind::Table)]); + let fetched = harness.fetched_columns(); + + let found = proposals( + &harness, + "WITH recent AS (SELECT * FROM users) SELECT | FROM recent r, (SELECT 1) s WHERE EXTRACT(YEAR FROM r.at) > 0", + ); + + assert!(labels(&found).contains(&"id")); assert_eq!( - model - .item(0) - .and_downcast::() - .map(|proposal| proposal.label().to_owned()), - Some("orders".to_owned()) + harness.fetched_columns() - fetched, + 1, + "a name the catalog does not hold was read" ); - harness.provider.refilter("", &model); - assert_eq!(model.n_items(), 2); +} + +#[gtk4::test] +fn a_database_prefix_lists_the_schemas_objects() { + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::ok(vec![ObjectInfo { + schema: Some("sales".to_owned()), + name: "invoices".to_owned(), + kind: ObjectKind::Table, + }])); + let harness = connected_with( + script, + vec![schema("dbo", true), schema("sales", false)], + vec![object("users", ObjectKind::Table)], + ); + let fetched = harness.fetched_columns(); + + let found = proposals(&harness, "SELECT * FROM shop.sales.|"); + + assert_eq!(labels(&found), ["invoices"]); + assert_eq!(harness.fetched_columns(), fetched, "the schema was read as a table"); +} + +#[gtk4::test] +fn a_schema_and_table_prefix_lists_the_tables_columns() { + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::ok(vec![ObjectInfo { + schema: Some("sales".to_owned()), + name: "invoices".to_owned(), + kind: ObjectKind::Table, + }])); + script + .fetch_columns + .when_sql_contains("invoices", FakeReply::ok(vec![column("total", "numeric")])); + let harness = connected_with( + script, + vec![schema("public", true), schema("sales", false)], + vec![object("users", ObjectKind::Table)], + ); + + let found = proposals(&harness, "SELECT sales.invoices.| FROM x"); + + assert_eq!(labels(&found), ["total"]); } /// The texts of the completion list GtkSourceView shows for `view`. From bd9690fada64369f6452341274b6f71450de412b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 23:23:28 +0700 Subject: [PATCH 113/178] perf(linux-editor): read only the text near the cursor to complete --- .../completion/sql_completion_provider.rs | 31 +++++++-- .../app/src/ui/editor/completion/tests.rs | 69 ++++++++++++++++++- 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs index 304cb97cc9..f3d3386d36 100644 --- a/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::sync::Arc; use gtk4::glib; @@ -26,6 +27,12 @@ glib::wrapper! { @implements sourceview5::CompletionProvider; } +/// How far before and after the cursor a completion reads the buffer, in +/// characters. A file of megabytes would otherwise be copied and lexed +/// on the GTK thread every time a word starts. +pub const CONTEXT_BEFORE: i32 = 128 * 1024; +pub const CONTEXT_AFTER: i32 = 32 * 1024; + pub struct ProviderDeps { session: ActiveSession, tasks: Tasks, @@ -130,9 +137,10 @@ impl SqlCompletionProvider { let grammar = dialect .as_ref() .map_or(SqlGrammar::PostgreSql, |dialect| dialect.grammar()); - let (start, end) = buffer.bounds(); + let (start, end) = context_bounds(buffer, word_start); let text = buffer.text(&start, &end, false); - let cursor = text_offsets::char_to_byte(&text, usize::try_from(word_start).unwrap_or_default()); + let cursor = usize::try_from(word_start - start.offset()).unwrap_or_default(); + let cursor = text_offsets::char_to_byte(&text, cursor); let scope = completion_scope::analyze(&text, cursor, grammar, LexicalSettings::default_for(grammar))?; Some(Request { scope, @@ -154,14 +162,13 @@ struct Request { impl Request { fn proposals(&self, answer: &CatalogAnswer) -> Vec { let mut proposals = Vec::new(); - let mut columns: Vec<&str> = Vec::new(); + let mut columns: HashSet<&str> = HashSet::new(); for (table, list) in &answer.columns { for column in list.iter() { // The same name in two joined tables is one thing to type. - if columns.contains(&column.name.as_str()) { + if !columns.insert(&column.name) { continue; } - columns.push(&column.name); let kind = ProposalKind::Column { data_type: column.column_type.name.clone(), table: table.clone(), @@ -212,6 +219,20 @@ impl Request { } } +/// The part of `buffer` a completion at `word_start` reads: all of an +/// ordinary buffer, and the lines around the cursor in a large one. +pub fn context_bounds(buffer: >k4::TextBuffer, word_start: i32) -> (gtk4::TextIter, gtk4::TextIter) { + let from = word_start.saturating_sub(CONTEXT_BEFORE).max(0); + let mut start = buffer.iter_at_offset(from); + let mut line = buffer.iter_at_offset(from); + // A window that opens mid-line would cut a word in two. + if from > 0 && line.forward_line() && line.offset() <= word_start { + start = line; + } + let end = buffer.iter_at_offset(word_start.saturating_add(CONTEXT_AFTER)); + (start, end) +} + /// What a column of the list shows for `proposal`, beside its icon and /// name. pub fn column_text(proposal: &SqlProposal, column: CompletionColumn) -> Option { diff --git a/linux/crates/app/src/ui/editor/completion/tests.rs b/linux/crates/app/src/ui/editor/completion/tests.rs index 2ba6859efd..d783c983c7 100644 --- a/linux/crates/app/src/ui/editor/completion/tests.rs +++ b/linux/crates/app/src/ui/editor/completion/tests.rs @@ -10,7 +10,9 @@ use tablepro_core::{ColumnInfo, TableRef}; use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; use super::proposal_kind::ProposalKind; -use super::sql_completion_provider::{SqlCompletionProvider, column_text, replace_word}; +use super::sql_completion_provider::{ + CONTEXT_AFTER, CONTEXT_BEFORE, SqlCompletionProvider, column_text, context_bounds, replace_word, +}; use super::sql_proposal::SqlProposal; use crate::services::active_session::ActiveSession; use crate::test_support::{FakeSession, MemorySettings, descendants, finish_on_main, test_runtime, wait_until}; @@ -392,6 +394,71 @@ fn a_schema_and_table_prefix_lists_the_tables_columns() { assert_eq!(labels(&found), ["total"]); } +#[gtk4::test] +fn a_large_buffer_is_read_only_near_the_cursor() { + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text(&format!("{}SELECT * FROM us", "SELECT 1;\n".repeat(30_000))); + let word_start = buffer.char_count() - 2; + + let (start, end) = context_bounds(&buffer, word_start); + + assert!(start.starts_line(), "the window opened mid-line"); + assert!(word_start - start.offset() <= CONTEXT_BEFORE); + assert!(end.is_end()); + let (start, end) = context_bounds(&buffer, 5); + assert!(start.is_start()); + assert_eq!(end.offset(), 5 + CONTEXT_AFTER); + let small = gtk4::TextBuffer::new(None); + small.set_text("SELECT * FROM us"); + let (start, end) = context_bounds(&small, 14); + assert!(start.is_start() && end.is_end(), "a small buffer was not read whole"); +} + +#[gtk4::test] +fn a_completion_at_the_end_of_a_large_buffer_still_finds_tables() { + let harness = connected(ConnectionScript::default(), vec![object("users", ObjectKind::Table)]); + let marked = format!("{}SELECT * FROM us|", "SELECT 1;\n".repeat(30_000)); + + let found = proposals(&harness, &marked); + + assert_eq!(labels(&found), ["users"]); +} + +#[gtk4::test] +fn nothing_is_proposed_inside_a_string() { + let harness = connected(ConnectionScript::default(), vec![object("users", ObjectKind::Table)]); + + assert!(proposals(&harness, "SELECT 'us|").is_empty()); +} + +#[gtk4::test] +fn refilter_narrows_the_list_without_reading_again() { + let harness = connected( + ConnectionScript::default(), + vec![object("users", ObjectKind::Table), object("orders", ObjectKind::Table)], + ); + let (text, start, word) = word_before("SELECT * FROM |"); + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text(&text); + let provider = harness.provider.clone(); + let model = + finish_on_main(WAIT, async move { provider.proposals(&buffer, start, &word).await }).expect("the proposals"); + assert_eq!(model.n_items(), 2); + + harness.provider.refilter("ord", &model); + + assert_eq!(model.n_items(), 1); + assert_eq!( + model + .item(0) + .and_downcast::() + .map(|proposal| proposal.label().to_owned()), + Some("orders".to_owned()) + ); + harness.provider.refilter("", &model); + assert_eq!(model.n_items(), 2); +} + /// The texts of the completion list GtkSourceView shows for `view`. fn shown(view: &sourceview5::View) -> Vec { descendants(view) From b07276946b308f6fe43eaead2fb47d11df3fb58d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 23:24:57 +0700 Subject: [PATCH 114/178] feat(linux-storage): find and move aside unreadable drafts --- .../crates/storage/src/drafts/draft_store.rs | 28 ++++++++++ linux/crates/storage/src/fs.rs | 54 ++++++++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/linux/crates/storage/src/drafts/draft_store.rs b/linux/crates/storage/src/drafts/draft_store.rs index 7bffcc4f3e..78ff4204eb 100644 --- a/linux/crates/storage/src/drafts/draft_store.rs +++ b/linux/crates/storage/src/drafts/draft_store.rs @@ -58,6 +58,18 @@ impl DraftStore { } } + /// Rename a draft that could not be read out of the way, and give the + /// name it now has. `retain_blocking` leaves the renamed file alone, + /// so its bytes stay there to recover. + pub fn move_aside_blocking( + &self, + scope: &DraftScope, + id: DraftId, + now: std::time::SystemTime, + ) -> Result { + crate::fs::move_aside_blocking(&self.draft_path(scope, id), now) + } + /// Delete every draft in the scope that no tab references any more, /// so a workspace that loses tabs does not leave files behind. pub fn retain_blocking(&self, scope: &DraftScope, keep: &HashSet) -> Result { @@ -191,6 +203,22 @@ mod tests { assert!(foreign.exists(), "retain deleted a file it did not write"); } + #[test] + fn a_draft_moved_aside_survives_retain() { + let root = tempfile::tempdir().expect("tempdir"); + let store = store(&root); + let id = DraftId::new(); + store.write_blocking(&scope(), id, "SELECT 1").expect("write"); + + let moved = store + .move_aside_blocking(&scope(), id, std::time::SystemTime::now()) + .expect("move aside"); + store.retain_blocking(&scope(), &HashSet::new()).expect("retain"); + + assert_eq!(store.read_blocking(&scope(), id).expect("read"), None); + assert_eq!(std::fs::read(&moved).expect("the moved draft"), b"SELECT 1"); + } + #[test] fn retain_on_a_scope_that_was_never_written_is_not_an_error() { let root = tempfile::tempdir().expect("tempdir"); diff --git a/linux/crates/storage/src/fs.rs b/linux/crates/storage/src/fs.rs index 2c83a865f7..71a35ad67c 100644 --- a/linux/crates/storage/src/fs.rs +++ b/linux/crates/storage/src/fs.rs @@ -106,9 +106,9 @@ pub fn move_aside_blocking(path: &Path, now: std::time::SystemTime) -> Result { @@ -135,6 +135,39 @@ pub fn move_aside_blocking(path: &Path, now: std::time::SystemTime) -> Result Result { + let Some(name) = path.file_name() else { + return Ok(false); + }; + let prefix = format!("{}{MOVED_ASIDE}", name.to_string_lossy()); + let parent = path.parent().unwrap_or(Path::new(".")); + let entries = match std::fs::read_dir(parent) { + Ok(entries) => entries, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false), + Err(source) => { + return Err(StorageError::Io { + path: parent.to_owned(), + source, + }); + } + }; + for entry in entries { + let entry = entry.map_err(|source| StorageError::Io { + path: parent.to_owned(), + source, + })?; + if entry.file_name().to_string_lossy().starts_with(&prefix) { + return Ok(true); + } + } + Ok(false) +} + #[cfg(test)] mod tests { use super::*; @@ -241,6 +274,23 @@ mod tests { assert!(!file.exists(), "the original was left behind"); } + #[test] + fn moved_aside_exists_only_for_that_file() { + let root = tempfile::tempdir().expect("tempdir"); + let file = root.path().join("a.json"); + let other = root.path().join("a.json2"); + std::fs::write(&file, b"a").expect("seed"); + std::fs::write(&other, b"b").expect("seed"); + assert!(!moved_aside_exists_blocking(&file).expect("list")); + + move_aside_blocking(&other, std::time::SystemTime::now()).expect("move"); + assert!(!moved_aside_exists_blocking(&file).expect("list")); + move_aside_blocking(&file, std::time::SystemTime::now()).expect("move"); + + assert!(moved_aside_exists_blocking(&file).expect("list")); + assert!(!moved_aside_exists_blocking(&root.path().join("missing").join("a.json")).expect("no directory")); + } + #[test] fn create_private_file_if_missing_keeps_existing_contents() { let root = tempfile::tempdir().expect("tempdir"); From 43ced9da15fbed68f81d4131186c254536dba572 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 23:24:57 +0700 Subject: [PATCH 115/178] fix(linux): restore every saved tab and cap the tab file by size Reset acts only on a file that could not be read. The store notes a copy moved aside, so the drafts it names are kept. --- linux/crates/app/src/workspace/tab_record.rs | 33 +++-- .../app/src/workspace/workspace_load_error.rs | 2 + .../app/src/workspace/workspace_record.rs | 40 +++-- .../app/src/workspace/workspace_store.rs | 137 +++++++++++++++++- 4 files changed, 174 insertions(+), 38 deletions(-) diff --git a/linux/crates/app/src/workspace/tab_record.rs b/linux/crates/app/src/workspace/tab_record.rs index ab70c77a44..ed929056a8 100644 --- a/linux/crates/app/src/workspace/tab_record.rs +++ b/linux/crates/app/src/workspace/tab_record.rs @@ -8,9 +8,6 @@ use super::SortRecord; /// What the page size preference allows. const PAGE_SIZES: RangeInclusive = 100..=100_000; const DEFAULT_PAGE_SIZE: u64 = 1_000; -/// No engine allows a name this long, so a record with one names no -/// table there is to reopen. -const MAX_NAME_BYTES: usize = 256; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] @@ -45,7 +42,7 @@ impl TabRecord { page_size, sort, } => { - if !names_fit(schema.as_deref(), &table) { + if table.is_empty() { return None; } let page_size = if PAGE_SIZES.contains(&page_size) { @@ -61,18 +58,12 @@ impl TabRecord { sort, }) } - Self::Structure { schema, table } => { - names_fit(schema.as_deref(), &table).then_some(Self::Structure { schema, table }) - } + Self::Structure { schema, table } => (!table.is_empty()).then_some(Self::Structure { schema, table }), Self::Editor { draft } => Some(Self::Editor { draft }), } } } -fn names_fit(schema: Option<&str>, table: &str) -> bool { - !table.is_empty() && table.len() <= MAX_NAME_BYTES && schema.is_none_or(|schema| schema.len() <= MAX_NAME_BYTES) -} - #[cfg(test)] mod tests { use super::*; @@ -98,13 +89,25 @@ mod tests { } #[test] - fn a_record_naming_no_possible_table_is_dropped() { + fn a_record_naming_no_table_is_dropped() { assert_eq!(table("", 1_000).clamped(), None); - assert_eq!(table(&"t".repeat(MAX_NAME_BYTES + 1), 1_000).clamped(), None); let structure = TabRecord::Structure { - schema: Some("s".repeat(MAX_NAME_BYTES + 1)), - table: "users".to_owned(), + schema: Some("public".to_owned()), + table: String::new(), }; assert_eq!(structure.clamped(), None); } + + #[test] + fn a_long_name_is_kept() { + // SQLite sets no limit on a name's length. + let long = table(&"t".repeat(1_000), 1_000); + let structure = TabRecord::Structure { + schema: Some("s".repeat(1_000)), + table: "users".to_owned(), + }; + + assert_eq!(long.clone().clamped(), Some(long)); + assert_eq!(structure.clone().clamped(), Some(structure)); + } } diff --git a/linux/crates/app/src/workspace/workspace_load_error.rs b/linux/crates/app/src/workspace/workspace_load_error.rs index 7b29786a62..11429de928 100644 --- a/linux/crates/app/src/workspace/workspace_load_error.rs +++ b/linux/crates/app/src/workspace/workspace_load_error.rs @@ -14,6 +14,8 @@ pub enum WorkspaceLoadError { Corrupt(String), #[error("the saved tabs are in a format this version does not read (found {found:?}, reads {expected})")] UnsupportedVersion { found: Option, expected: u32 }, + #[error("the saved tabs file is larger than {limit} bytes")] + TooLarge { limit: u64 }, } impl From for WorkspaceLoadError { diff --git a/linux/crates/app/src/workspace/workspace_record.rs b/linux/crates/app/src/workspace/workspace_record.rs index 0334d2e502..95820969ab 100644 --- a/linux/crates/app/src/workspace/workspace_record.rs +++ b/linux/crates/app/src/workspace/workspace_record.rs @@ -3,9 +3,6 @@ use tablepro_storage::document::VersionedDocument; use super::TabRecord; -/// More tabs than this is a file to distrust, not a window to rebuild. -const MAX_TABS: usize = 32; - /// One session's saved tabs, in the order the window showed them. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkspaceRecord { @@ -23,17 +20,18 @@ impl VersionedDocument for WorkspaceRecord { } impl WorkspaceRecord { - /// The record as a window can rebuild it: at most `MAX_TABS` tabs, - /// none naming something that could not exist, and `active_tab` - /// still pointing at the tab it named, or at none. + /// The record as a window can rebuild it: no tab naming something + /// that could not exist, and `active_tab` still pointing at the tab + /// it named, or at none. + /// + /// The tab count is not capped: the window saves every tab it has, + /// and an editor tab cut here would have its draft deleted as one + /// no tab names. The store's cap on the file size bounds a load. pub fn clamped(self) -> Self { let active = self.active_tab.and_then(|index| usize::try_from(index).ok()); - let mut tabs = Vec::with_capacity(self.tabs.len().min(MAX_TABS)); + let mut tabs = Vec::with_capacity(self.tabs.len()); let mut active_tab = None; for (index, tab) in self.tabs.into_iter().enumerate() { - if tabs.len() == MAX_TABS { - break; - } let Some(tab) = tab.clamped() else { continue; }; @@ -99,16 +97,28 @@ mod tests { } #[test] - fn clamped_limits_tabs_and_resets_active_tab() { + fn clamped_keeps_every_tab_however_many() { let record = WorkspaceRecord { active_tab: Some(35), - tabs: (0..40).map(|index| table(&format!("t{index}"))).collect(), + tabs: (0..40) + .map(|index| match index % 2 { + 0 => TabRecord::Editor { draft: DraftId::new() }, + _ => table(&format!("t{index}")), + }) + .collect(), }; - let clamped = record.clamped(); + assert_eq!(record.clone().clamped(), record); + } - assert_eq!(clamped.tabs.len(), MAX_TABS); - assert_eq!(clamped.active_tab, None); + #[test] + fn clamped_resets_an_active_tab_past_the_end() { + let record = WorkspaceRecord { + active_tab: Some(3), + tabs: vec![table("a"), table("b")], + }; + + assert_eq!(record.clamped().active_tab, None); } #[test] diff --git a/linux/crates/app/src/workspace/workspace_store.rs b/linux/crates/app/src/workspace/workspace_store.rs index ecb0ac5781..085aac9f5c 100644 --- a/linux/crates/app/src/workspace/workspace_store.rs +++ b/linux/crates/app/src/workspace/workspace_store.rs @@ -1,6 +1,6 @@ use std::cell::RefCell; use std::collections::{BTreeMap, HashMap, HashSet}; -use std::io::ErrorKind; +use std::io::{ErrorKind, Read}; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; @@ -16,6 +16,10 @@ use super::{WorkspaceFileName, WorkspaceLoad, WorkspaceLoadError, WorkspaceRecor type WriteFile = dyn Fn(&Path, &[u8]) -> Result<(), StorageError> + Send + Sync; +/// Thousands of tabs encode to less than this. A file past it is read +/// no further, so a runaway one costs no memory to refuse. +const MAX_FILE_BYTES: u64 = 1024 * 1024; + /// Every session's saved tabs, one file each. /// /// Writes go to one task that keeps only the newest record per file, so @@ -32,6 +36,7 @@ struct StoreInner { tasks: Tasks, writer: mpsc::UnboundedSender, blocked: RefCell>, + moved_aside: RefCell>, } impl WorkspaceStore { @@ -50,6 +55,7 @@ impl WorkspaceStore { tasks: tasks.clone(), writer, blocked: RefCell::default(), + moved_aside: RefCell::default(), }), } } @@ -65,11 +71,21 @@ impl WorkspaceStore { // A failed write still leaves whatever is on disk to read. let _ = written.await; let reading = path.clone(); - let load = inner + let (load, moved_aside) = inner .tasks - .spawn_blocking_task(move || read_record(&reading)) + .spawn_blocking_task(move || (read_record(&reading), moved_aside_exists(&reading))) .await - .unwrap_or_else(|failure| WorkspaceLoad::Blocked(WorkspaceLoadError::Io(failure.to_string()))); + .unwrap_or_else(|failure| { + ( + WorkspaceLoad::Blocked(WorkspaceLoadError::Io(failure.to_string())), + true, + ) + }); + if moved_aside { + inner.moved_aside.borrow_mut().insert(key.clone()); + } else { + inner.moved_aside.borrow_mut().remove(&key); + } match &load { WorkspaceLoad::Blocked(error) => { tracing::warn!(%error, path = %path.display(), "keeping the saved tabs as they are"); @@ -112,18 +128,27 @@ impl WorkspaceStore { /// Move a file that could not be read aside, bytes and all, and let /// the session save again. Gives the name the file now has, or `None` - /// when there was no file. + /// when nothing was moved: there was no file, or it could be read. pub fn reset(&self, key: &SessionKey) -> impl Future, WorkspaceSaveError>> + use<> { + // Checked now: a Reset clicked after a switch to another session + // names that session, whose file may be fine. + let blocked = self.is_blocked(key); let inner = self.inner.clone(); let key = key.clone(); let path = self.inner.path(&key); async move { + if !blocked { + return Ok(None); + } let moved = inner .tasks .spawn_blocking_task(move || move_aside(&path)) .await .unwrap_or_else(|failure| Err(WorkspaceSaveError::MoveAside(failure.to_string())))?; inner.blocked.borrow_mut().remove(&key); + if moved.is_some() { + inner.moved_aside.borrow_mut().insert(key); + } Ok(moved) } } @@ -131,6 +156,13 @@ impl WorkspaceStore { pub fn is_blocked(&self, key: &SessionKey) -> bool { self.inner.blocked.borrow().contains(key) } + + /// Whether a copy of the session's file that Reset moved aside is + /// still there, as of the last load or reset. It may name drafts + /// that no saved tab does. + pub fn has_moved_aside_copy(&self, key: &SessionKey) -> bool { + self.inner.moved_aside.borrow().contains(key) + } } impl StoreInner { @@ -210,8 +242,9 @@ async fn write_record( } fn read_record(path: &Path) -> WorkspaceLoad { - let bytes = match std::fs::read(path) { - Ok(bytes) => bytes, + let bytes = match read_capped(path) { + Ok(Some(bytes)) => bytes, + Ok(None) => return WorkspaceLoad::Blocked(WorkspaceLoadError::TooLarge { limit: MAX_FILE_BYTES }), Err(error) if error.kind() == ErrorKind::NotFound => return WorkspaceLoad::Empty, Err(error) => return WorkspaceLoad::Blocked(WorkspaceLoadError::Io(error.to_string())), }; @@ -221,6 +254,27 @@ fn read_record(path: &Path) -> WorkspaceLoad { } } +/// The whole file, or `None` when it is larger than `MAX_FILE_BYTES`. +fn read_capped(path: &Path) -> std::io::Result>> { + let file = std::fs::File::open(path)?; + if file.metadata()?.len() > MAX_FILE_BYTES { + return Ok(None); + } + let mut bytes = Vec::new(); + // The file can grow after the size was read. + file.take(MAX_FILE_BYTES + 1).read_to_end(&mut bytes)?; + Ok((bytes.len() as u64 <= MAX_FILE_BYTES).then_some(bytes)) +} + +/// A directory that cannot be listed counts as holding a copy, so +/// nothing is deleted on a guess. +fn moved_aside_exists(path: &Path) -> bool { + tablepro_storage::fs::moved_aside_exists_blocking(path).unwrap_or_else(|error| { + tracing::warn!(%error, "could not look for saved tabs moved aside"); + true + }) +} + fn move_aside(path: &Path) -> Result, WorkspaceSaveError> { if let Err(error) = std::fs::symlink_metadata(path) && error.kind() == ErrorKind::NotFound @@ -415,8 +469,75 @@ mod tests { #[tokio::test] async fn reset_without_a_file_unblocks() { let fixture = Fixture::new(); + let key = key(); + let path = fixture.seed(&key, b"{ not json"); + assert!(matches!(fixture.store.load(&key).await, WorkspaceLoad::Blocked(_))); + std::fs::remove_file(&path).expect("remove"); + + assert_eq!(fixture.store.reset(&key).await, Ok(None)); + assert!(!fixture.store.is_blocked(&key)); + } + + #[tokio::test] + async fn reset_leaves_a_readable_file_alone() { + let fixture = Fixture::new(); + let key = key(); + fixture.store.schedule(&key, record(1)).expect("scheduled"); + assert_eq!(fixture.store.load(&key).await, WorkspaceLoad::Restored(record(1))); + + assert_eq!(fixture.store.reset(&key).await, Ok(None)); + + assert_eq!(fixture.on_disk(&key), record(1)); + assert!(!fixture.store.has_moved_aside_copy(&key)); + } + + #[tokio::test] + async fn a_copy_moved_aside_is_remembered() { + let fixture = Fixture::new(); + let (key, other) = (key(), key()); + fixture.seed(&key, b"{ not json"); + assert!(matches!(fixture.store.load(&key).await, WorkspaceLoad::Blocked(_))); + assert!(!fixture.store.has_moved_aside_copy(&key)); + + fixture.store.reset(&key).await.expect("reset"); + assert!(fixture.store.has_moved_aside_copy(&key)); + // As a later run finds it on disk. + let restarted = WorkspaceStore::start(fixture.root.path().join("workspaces"), &paused_tasks()); + assert_eq!(restarted.load(&key).await, WorkspaceLoad::Empty); + restarted.load(&other).await; - assert_eq!(fixture.store.reset(&key()).await, Ok(None)); + assert!(restarted.has_moved_aside_copy(&key)); + assert!(!restarted.has_moved_aside_copy(&other)); + } + + #[tokio::test] + async fn a_record_of_many_tabs_loads_whole() { + let fixture = Fixture::new(); + let key = key(); + let many = WorkspaceRecord { + active_tab: Some(39), + tabs: (0..40).map(|_| TabRecord::Editor { draft: DraftId::new() }).collect(), + }; + fixture.store.schedule(&key, many.clone()).expect("scheduled"); + + assert_eq!(fixture.store.load(&key).await, WorkspaceLoad::Restored(many)); + } + + #[tokio::test] + async fn an_oversized_file_blocks_key() { + let fixture = Fixture::new(); + let key = key(); + let padding = " ".repeat(usize::try_from(MAX_FILE_BYTES).expect("a size")); + let path = fixture.seed(&key, format!(r#"{{"version": 1, "tabs": []{padding}}}"#).as_bytes()); + let size = std::fs::metadata(&path).expect("metadata").len(); + + let load = fixture.store.load(&key).await; + + assert_eq!( + load, + WorkspaceLoad::Blocked(WorkspaceLoadError::TooLarge { limit: MAX_FILE_BYTES }) + ); + assert_eq!(std::fs::metadata(&path).expect("metadata").len(), size); } #[tokio::test] From 5d728c52a9614f11e99bfea2fcce6b7610e89390 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 23:24:57 +0700 Subject: [PATCH 116/178] fix(linux): save editor text on disconnect and after a reconnect A draft that cannot be read is moved aside, and a toast says so. --- linux/ARCHITECTURE.md | 4 +- linux/CHANGELOG.md | 6 +- linux/crates/app/src/ui/app/workspace_tabs.rs | 86 ++++++++++--------- .../crates/app/src/workspace/draft_writer.rs | 55 ++++++++++++ linux/crates/app/src/workspace/mod.rs | 2 + .../app/src/workspace/restored_drafts.rs | 81 +++++++++++++++++ linux/po/POTFILES.in | 1 + 7 files changed, 191 insertions(+), 44 deletions(-) create mode 100644 linux/crates/app/src/workspace/restored_drafts.rs diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index ded8b99666..309ddffb77 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -243,7 +243,9 @@ The Structure tab is **snapshot + diff**, not per-op log. `original_*` snapshots | Per-table column widths | JSON, durable write | `$XDG_CONFIG_HOME/tablepro/column_widths.json` | | Single-instance lock | `flock` on an empty file | `$XDG_RUNTIME_DIR/app//tablepro.lock` | -`WorkspaceStore` reads a session's file once its session is adopted, and the window writes the tab list only after that read, so a write never lands on tabs not yet restored. A file that cannot be read, or that another version wrote, is left as it is and nothing is written over it until the user picks Reset on the toast, which moves it aside as `.json.corrupt-`. Writes go through one task that keeps the newest record per file. Editor records carry a draft id, never the text, and the editor's `content-changed` signal carries no text either: the tab label reads at most 200 characters. +`WorkspaceStore` reads a session's file once its session is adopted, and the window writes the tab list only after that read, so a write never lands on tabs not yet restored. A file that cannot be read, that another version wrote, or that is over 1 MiB is left as it is and nothing is written over it until the user picks Reset on the toast, which moves it aside as `.json.corrupt-`. Every open tab is saved and restored, with no cap on the count. Writes go through one task that keeps the newest record per file. Editor records carry a draft id, never the text, and the editor's `content-changed` signal carries no text either: the tab label reads at most 200 characters. + +A restore deletes the drafts no saved or open tab names, except while a moved-aside copy of the session's file is there, since that copy may name them. A draft that cannot be read is moved aside as `.sql.corrupt-`, which the cleanup never deletes, and its tab opens empty with a toast. A disconnect writes the open tabs' text before their buffers go. `SavedConnection::last_opened_at: Option>` is stamped on each successful connect via `touch_last_opened`. The welcome view sorts by recency-first with alphabetical tiebreaker; never-opened entries fall to the bottom. diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 34a803c51e..e4f5e34470 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -49,9 +49,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A refresh that failed replaced the rows in a table tab with an error page. - Switching connections could show the previous connection's tables in the sidebar, or its reconnect banner. - Switching connections during a table drop could close the new connection's tabs on a table of the same name. -- Saved tabs were lost on a disconnect or window close right after a tab change, and for every connection when their file could not be read. +- Saved tabs and editor text were lost on a disconnect or window close right after a change. +- Every connection's saved tabs were lost when their file could not be read. - Opening a connection emptied the editor tabs saved for other connections. - Structure tabs did not reopen with their connection. +- Reopening a connection's tabs stopped at 32 tabs and cut table names longer than 256 bytes. +- Editor tabs reopened after a reconnect in the same window stopped saving their text. +- An editor tab whose saved text could not be read opened empty with no notice, and typing in it overwrote that text. - Typing in a long SQL script copied the whole script on every keystroke. ### Security diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index adb5c99415..8ac97d2bbd 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -15,7 +15,9 @@ use crate::services::from_session::FromSession; use crate::ui::browse_tab::{BrowseTab, BrowseTabInit, BrowseTabInput, ExportRequest, SaveRequest}; use crate::ui::editor::{SqlEditor, SqlEditorInit, derive_tab_label}; use crate::ui::structure_tab::{StructureMode, StructureTab, StructureTabInit}; -use crate::workspace::{TabRecord, WorkspaceFileName, WorkspaceLoad, WorkspaceRecord, WorkspaceSaveError}; +use crate::workspace::{ + RestoredDrafts, TabRecord, WorkspaceFileName, WorkspaceLoad, WorkspaceRecord, WorkspaceSaveError, +}; use super::workspace_persistence::reopen; use super::{ @@ -87,18 +89,7 @@ impl App { WorkspaceLoad::Restored(record) => saved_drafts(record).collect(), WorkspaceLoad::Empty | WorkspaceLoad::Blocked(_) => Vec::new(), }; - let reading = tasks.spawn_blocking_task(move || { - referenced - .into_iter() - .filter_map(|draft| match store.read_blocking(&scope, draft) { - Ok(text) => text.map(|text| (draft, text)), - Err(error) => { - tracing::warn!(%error, "could not read an editor draft"); - None - } - }) - .collect::>() - }); + let reading = tasks.spawn_blocking_task(move || RestoredDrafts::read_blocking(&store, &scope, referenced)); let drafts = reading.await.unwrap_or_default(); sender.input(AppMsg::WorkspaceLoaded(FromSession::new( origin, @@ -112,35 +103,50 @@ impl App { /// Nothing is written for the session before this: a write would /// replace the saved tabs before they were read. pub(super) fn on_workspace_loaded(&mut self, loaded: LoadedWorkspace, sender: ComponentSender) { - let LoadedWorkspace { key, load, mut drafts } = loaded; - let scope = WorkspaceFileName::for_key(&key).draft_scope(); + let LoadedWorkspace { key, load, drafts } = loaded; + let RestoredDrafts { mut texts, unreadable } = drafts; match load { WorkspaceLoad::Restored(record) => { - self.retain_drafts(&scope, saved_drafts(&record)); + self.retain_drafts(&key, saved_drafts(&record)); if let Some(view) = self.workspace_tab_view.clone() { - reopen(&view, record, |tab| self.reopen_tab(tab, &mut drafts, sender.clone())); + reopen(&view, record, |tab| self.reopen_tab(tab, &mut texts, sender.clone())); } } - WorkspaceLoad::Empty => self.retain_drafts(&scope, std::iter::empty()), + WorkspaceLoad::Empty => self.retain_drafts(&key, std::iter::empty()), // Which drafts the file names is unknown, so none is deleted. WorkspaceLoad::Blocked(_) => self.show_unreadable_workspace_toast(), } + if unreadable > 0 { + let count = u32::try_from(unreadable).unwrap_or(u32::MAX); + self.show_toast(&crate::i18n::ngettext_f( + "Saved text of {n} query tab could not be read. The file was kept.", + "Saved text of {n} query tabs could not be read. The files were kept.", + count, + &[("n", &unreadable.to_string())], + )); + } if let Some(view) = self.workspace_tab_view.as_ref() { self.workspace.attach(key, view, self.workspace_tabs.clone()); self.persist_workspace_state(); } } - /// Delete the drafts in `scope` that neither the saved tabs nor an + /// Delete the session's drafts that neither the saved tabs nor an /// open tab name: those of tabs closed in a session that did not /// shut down cleanly. - fn retain_drafts(&self, scope: &DraftScope, saved: impl Iterator) { + fn retain_drafts(&self, key: &SessionKey, saved: impl Iterator) { + // A copy that Reset moved aside may name drafts the saved tabs + // do not, and it leads back to them only while they are kept. + if self.storage.workspaces().has_moved_aside_copy(key) { + return; + } + let scope = WorkspaceFileName::for_key(key).draft_scope(); let mut keep: HashSet = saved.collect(); keep.extend(self.workspace_tabs.borrow().values().filter_map(|tab| match tab { WorkspaceTab::Editor(slot) => Some(slot.draft), _ => None, })); - self.drafts.retain(scope, keep); + self.drafts.retain(&scope, keep); } fn reopen_tab( @@ -165,9 +171,8 @@ impl App { sender, ), TabRecord::Structure { schema, table } => self.append_existing_structure_tab(schema, table, sender), - // A draft that is missing or could not be read opens empty, - // and is not written back until the user types, so its bytes - // stay on disk. + // Its text is on disk already, or there is none to keep, so + // nothing is written until the user types. TabRecord::Editor { draft } => self.append_editor_tab_with_draft( Some(drafts.remove(&draft).unwrap_or_default()), draft, @@ -955,16 +960,22 @@ impl App { self.workspace_tabs.borrow_mut().clear(); } - /// Write the tabs as they are now, before the teardown empties them. - /// The process is held until the write lands, so quitting right - /// after a disconnect does not lose it. + /// Write the tabs and the editor text as they are now, before the + /// teardown frees them. The process is held until the writes land, + /// so quitting right after a disconnect does not lose them. fn flush_workspace_state(&self) { - let Some(flushing) = self.workspace.detach() else { - return; - }; + let tabs = self.workspace.detach(); + let drafts = self.drafts.flush_all(); + let writer = self.drafts.clone(); let hold = self.context.hold.acquire(); glib::spawn_future_local(async move { - if let Err(error) = flushing.await { + for failure in drafts.await { + tracing::warn!(failure, "an editor draft was not saved"); + } + writer.release_closed(); + if let Some(tabs) = tabs + && let Err(error) = tabs.await + { tracing::debug!(%error, "the open tabs were not saved"); } drop(hold); @@ -1357,20 +1368,11 @@ enum DraftWrite { OnChange, } +#[derive(Debug)] pub(crate) struct LoadedWorkspace { key: SessionKey, load: WorkspaceLoad, - drafts: HashMap, -} - -impl std::fmt::Debug for LoadedWorkspace { - // The drafts are the user's SQL, which stays out of the journal. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("LoadedWorkspace") - .field("key", &self.key) - .field("drafts", &self.drafts.len()) - .finish_non_exhaustive() - } + drafts: RestoredDrafts, } fn saved_drafts(record: &WorkspaceRecord) -> impl Iterator + '_ { diff --git a/linux/crates/app/src/workspace/draft_writer.rs b/linux/crates/app/src/workspace/draft_writer.rs index 597159fe42..fbf1f77886 100644 --- a/linux/crates/app/src/workspace/draft_writer.rs +++ b/linux/crates/app/src/workspace/draft_writer.rs @@ -101,6 +101,16 @@ impl DraftWriter { }); } + /// Stop tracking the tabs whose buffers are gone. Their drafts stay + /// on disk for the tab to be restored from. Call it once what + /// `flush_all` started has landed, since a dropped slot stops its + /// writer. + pub fn release_closed(&self) { + self.slots + .borrow_mut() + .retain(|_, slot| slot.buffer.upgrade().is_some()); + } + pub fn store(&self) -> &DraftStore { &self.store } @@ -108,6 +118,11 @@ impl DraftWriter { fn slot(&self, scope: &DraftScope, id: DraftId, buffer: >k4::TextBuffer) -> Rc { let mut slots = self.slots.borrow_mut(); if let Some(slot) = slots.get(&id) { + // A tab restored after a reconnect keeps its draft id but has + // a new buffer, and the old one is gone. + if slot.buffer.upgrade().as_ref() != Some(buffer) { + slot.buffer.set(Some(buffer)); + } return slot.clone(); } let store = self.store.clone(); @@ -287,6 +302,46 @@ mod tests { assert_eq!(fixture.read(id).as_deref(), Some("SELECT 1")); } + #[gtk4::test] + fn a_restored_tab_writes_through_its_new_buffer() { + let fixture = Fixture::new(); + let id = DraftId::new(); + { + let before = gtk4::TextBuffer::new(None); + before.set_text("SELECT 1"); + fixture.writer.schedule(&fixture.scope, id, &before); + fixture.wait_for(id, "SELECT 1"); + } + + let after = gtk4::TextBuffer::new(None); + after.set_text("SELECT 2"); + fixture.writer.schedule(&fixture.scope, id, &after); + + fixture.wait_for(id, "SELECT 2"); + } + + #[gtk4::test] + fn release_closed_keeps_open_tabs_and_their_drafts() { + let fixture = Fixture::new(); + let (closed, open) = (DraftId::new(), DraftId::new()); + let kept = gtk4::TextBuffer::new(None); + kept.set_text("SELECT 2"); + { + let gone = gtk4::TextBuffer::new(None); + gone.set_text("SELECT 1"); + fixture.writer.schedule(&fixture.scope, closed, &gone); + fixture.writer.schedule(&fixture.scope, open, &kept); + fixture.flush_all(); + } + + fixture.writer.release_closed(); + + let tracked: Vec = fixture.writer.slots.borrow().keys().copied().collect(); + assert_eq!(tracked, [open]); + assert_eq!(fixture.read(closed).as_deref(), Some("SELECT 1")); + assert_eq!(fixture.read(open).as_deref(), Some("SELECT 2")); + } + #[gtk4::test] fn retain_removes_the_drafts_no_tab_references() { let fixture = Fixture::new(); diff --git a/linux/crates/app/src/workspace/mod.rs b/linux/crates/app/src/workspace/mod.rs index b9f3b24841..7532bdd637 100644 --- a/linux/crates/app/src/workspace/mod.rs +++ b/linux/crates/app/src/workspace/mod.rs @@ -3,6 +3,7 @@ mod draft_writer; mod persist_debouncer; +mod restored_drafts; mod sort_record; mod tab_record; mod workspace_file_name; @@ -15,6 +16,7 @@ mod write_job; pub use draft_writer::DraftWriter; pub use persist_debouncer::PersistDebouncer; +pub use restored_drafts::RestoredDrafts; pub use sort_record::SortRecord; pub use tab_record::TabRecord; pub use workspace_file_name::WorkspaceFileName; diff --git a/linux/crates/app/src/workspace/restored_drafts.rs b/linux/crates/app/src/workspace/restored_drafts.rs new file mode 100644 index 0000000000..1eda9801f0 --- /dev/null +++ b/linux/crates/app/src/workspace/restored_drafts.rs @@ -0,0 +1,81 @@ +use std::collections::HashMap; + +use tablepro_storage::{DraftId, DraftScope, DraftStore}; + +/// The text of a restored session's editor tabs. +#[derive(Default)] +pub struct RestoredDrafts { + pub texts: HashMap, + /// How many drafts could not be read. Each is moved aside, so its tab + /// opens empty and neither typing in it nor closing it touches the + /// bytes. + pub unreadable: usize, +} + +impl RestoredDrafts { + pub fn read_blocking(store: &DraftStore, scope: &DraftScope, drafts: impl IntoIterator) -> Self { + let mut restored = Self::default(); + for draft in drafts { + match store.read_blocking(scope, draft) { + Ok(Some(text)) => { + restored.texts.insert(draft, text); + } + Ok(None) => {} + Err(error) => { + tracing::warn!(%error, "could not read an editor draft"); + restored.unreadable += 1; + match store.move_aside_blocking(scope, draft, std::time::SystemTime::now()) { + Ok(moved) => tracing::info!(moved = %moved.display(), "moved the unreadable draft aside"), + Err(error) => tracing::warn!(%error, "could not move the unreadable draft aside"), + } + } + } + } + restored + } +} + +impl std::fmt::Debug for RestoredDrafts { + // The drafts are the user's SQL, which stays out of the journal. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RestoredDrafts") + .field("texts", &self.texts.len()) + .field("unreadable", &self.unreadable) + .finish() + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + #[test] + fn an_unreadable_draft_is_moved_aside() { + let root = tempfile::tempdir().expect("tempdir"); + let store = DraftStore::new(root.path().join("drafts")); + let scope = DraftScope::new("session").expect("a scope"); + let (good, bad, missing) = (DraftId::new(), DraftId::new(), DraftId::new()); + store.write_blocking(&scope, good, "SELECT 1").expect("write"); + store.write_blocking(&scope, bad, "SELECT 2").expect("write"); + let bad_path = root + .path() + .join("drafts") + .join(scope.as_str()) + .join(format!("{bad}.sql")); + std::fs::write(&bad_path, b"SELECT \xff").expect("seed bytes that are not UTF-8"); + + let restored = RestoredDrafts::read_blocking(&store, &scope, [good, bad, missing]); + + assert_eq!(restored.texts, HashMap::from([(good, "SELECT 1".to_owned())])); + assert_eq!(restored.unreadable, 1); + assert!(!bad_path.exists(), "the unreadable draft is where its tab writes"); + store.retain_blocking(&scope, &HashSet::new()).expect("retain"); + let kept: Vec> = std::fs::read_dir(bad_path.parent().expect("the scope")) + .expect("list") + .map(|entry| std::fs::read(entry.expect("an entry").path()).expect("read")) + .collect(); + assert_eq!(kept, [b"SELECT \xff".to_vec()]); + } +} diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 6bce0f9c1a..4b816a0412 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -148,6 +148,7 @@ crates/app/src/ui/window_geometry.rs crates/app/src/workspace/draft_writer.rs crates/app/src/workspace/mod.rs crates/app/src/workspace/persist_debouncer.rs +crates/app/src/workspace/restored_drafts.rs crates/app/src/workspace/sort_record.rs crates/app/src/workspace/tab_record.rs crates/app/src/workspace/workspace_file_name.rs From a8ec2f2e3999e184f890b11870e3fc7659b43100 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 23:24:58 +0700 Subject: [PATCH 117/178] test(linux): cover structure tab saving and the detach timer --- .../app/src/ui/app/workspace_persistence.rs | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/src/ui/app/workspace_persistence.rs b/linux/crates/app/src/ui/app/workspace_persistence.rs index 93ed9f0489..2bff0ef528 100644 --- a/linux/crates/app/src/ui/app/workspace_persistence.rs +++ b/linux/crates/app/src/ui/app/workspace_persistence.rs @@ -78,8 +78,8 @@ impl WorkspacePersistence { /// Write the tabs as they are now and stop writing for this session. /// The future resolves once the record is on disk. pub fn detach(&self) -> Option> + use<>> { - // A request still pending would read the tabs after the teardown - // emptied them, and save an empty list. + // Taking the target is what keeps a pending request from saving + // the emptied tabs; this only stops its timer. self.debouncer.cancel(); let target = self.target.borrow_mut().take()?; if let Some(record) = target.record() { @@ -279,6 +279,56 @@ mod tests { assert_eq!(record.active_tab, Some(1)); } + #[gtk4::test] + fn only_a_structure_tab_of_an_existing_table_is_saved() { + let fixture = Fixture::new(); + let session = crate::test_support::FakeSession::start( + tablepro_test_support::ConnectionScript::default(), + [], + fixture.runtime.tasks(), + ); + for (mode, table) in [(StructureMode::Edit, "users"), (StructureMode::New, "")] { + let structure = crate::ui::structure_tab::StructureTab::new(crate::ui::structure_tab::StructureTabInit { + tracker: Default::default(), + schema: Some("public".to_owned()), + table: table.to_owned(), + mode, + driver_id: "postgres".to_owned(), + session: session.session.clone(), + tasks: fixture.runtime.tasks(), + }); + let page = fixture.view.append(&structure); + let id = Uuid::new_v4(); + write_workspace_tab_id(&page, id); + fixture.tabs.borrow_mut().insert( + id, + WorkspaceTab::Structure(crate::ui::app::StructureTabSlot { + id, + structure, + page, + schema: Some("public".to_owned()), + table: table.to_owned(), + mode, + }), + ); + } + + let record = workspace_record(&fixture.view, &fixture.tabs.borrow()); + + assert_eq!( + record.tabs, + [TabRecord::Structure { + schema: Some("public".to_owned()), + table: "users".to_owned(), + }] + ); + for tab in fixture.tabs.borrow().values() { + if let WorkspaceTab::Structure(slot) = tab { + slot.structure.shutdown(); + } + } + } + #[gtk4::test] fn restore_selects_active_tab_and_reopens_structure_tabs() { adw::init().expect("libadwaita initialises under the test backend"); @@ -351,6 +401,10 @@ mod tests { persistence.request(); let flushing = persistence.detach().expect("an attached session"); + assert!( + !persistence.debouncer.is_pending(), + "a write timer outlived the session" + ); // What the window's teardown does next. fixture.tabs.borrow_mut().clear(); finish_on_main(WAIT, flushing) From d6c5517dfe72864c164a9153e05703c5f458e1f2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 13 Oct 2026 23:24:58 +0700 Subject: [PATCH 118/178] fix(linux): read and reset saved connections off the GTK thread --- .github/workflows/build-linux.yml | 8 +- linux/crates/app/src/ui/app/connection.rs | 118 ++++++++++++---------- 2 files changed, 72 insertions(+), 54 deletions(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 7ad24e5f7c..6bfef54103 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -108,13 +108,15 @@ jobs: fi - name: No blocking file I/O in the window and editor code # This code runs on the GTK thread, where a slow disk or a - # network mount freezes the window. Tests may use std::fs. + # network mount freezes the window. A storage call ending in + # _blocking is handed to spawn_blocking_task on the same line, + # which is what this check can see. Tests may do either. run: | found=$(find crates/app/src/ui/app crates/app/src/ui/editor -name '*.rs' ! -name tests.rs -print0 \ - | xargs -0 awk '/^mod tests \{/ { nextfile } /std::fs::/ { print FILENAME ":" FNR ": " $0 }') + | xargs -0 awk '/^mod tests \{/ { nextfile } /std::fs::/ || (/_blocking\(/ && !/spawn_blocking_task\(/) { print FILENAME ":" FNR ": " $0 }') if [ -n "$found" ]; then echo "$found" - echo "::error::crates/app/src/ui/app and crates/app/src/ui/editor must not call std::fs" + echo "::error::crates/app/src/ui/app and crates/app/src/ui/editor must not call std::fs, or a _blocking function outside spawn_blocking_task" exit 1 fi - name: Clippy diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index 7b6ac8499a..70c242e099 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -579,10 +579,7 @@ impl App { drop(spawner.spawn_task(async move { let copy_id = copy.id; let saved = tasks - .spawn_blocking_task({ - let connections = connections.clone(); - move || connections.upsert_blocking(copy) - }) + .spawn_blocking_task(move || connections.upsert_blocking(copy)) .await; if let Ok(Err(error)) = saved { tracing::warn!(%error, "could not save the duplicated connection"); @@ -669,38 +666,24 @@ impl App { pub(super) fn on_export_connections(&self, sender: ComponentSender) { self.connections_popover.popdown(); let connections = self.storage.connections().clone(); - let bytes = match connections.export_blocking() { - Ok(bytes) => bytes, - Err(error) => { - tracing::warn!(%error, "could not read the connections to export"); - self.show_toast(&crate::i18n::gettext("The connections could not be read.")); - return; - } - }; - - let dialog = gtk::FileDialog::builder() - .title(crate::i18n::gettext("Export Connections")) - .modal(true) - .initial_name("tablepro-connections.json") - .default_filter(&json_filter()) - .filters(&json_filters()) - .build(); + let reading = self.tasks.spawn_blocking_task(move || connections.export_blocking()); + let window = self.window.downgrade(); let input = sender.input_sender().clone(); - dialog.save(Some(&self.window), gio::Cancellable::NONE, move |outcome| { - let Ok(file) = outcome else { return }; - glib::spawn_future_local(async move { - let written = file - .replace_contents_future(bytes, None, false, gio::FileCreateFlags::NONE) - .await; - let message = match written { - Ok(_) => crate::i18n::gettext("Connections exported."), - Err((_, error)) => { - tracing::warn!(%error, uri = %file.uri(), "could not write the exported connections"); - crate::i18n::gettext("The connections could not be written to that file.") + glib::spawn_future_local(async move { + let error = match reading.await { + Ok(Ok(bytes)) => { + if let Some(window) = window.upgrade() { + ask_where_to_export(&window, bytes, input); } - }; - let _ = input.send(AppMsg::ShowToast(message)); - }); + return; + } + Ok(Err(error)) => error.to_string(), + Err(error) => error.to_string(), + }; + tracing::warn!(%error, "could not read the connections to export"); + let _ = input.send(AppMsg::ShowToast(crate::i18n::gettext( + "The connections could not be read.", + ))); }); } @@ -1063,35 +1046,68 @@ impl App { dialog.set_close_response("cancel"); let store = self.storage.connections().clone(); + let tasks = self.tasks.clone(); let banner = self.connection_list_banner.clone(); let toasts = self.toast_overlay.clone(); - let sender_for_reset = sender; dialog.connect_response(None, move |dialog, response| { dialog.close(); if response != "reset" { return; } - match store.reset_unreadable_blocking(std::time::SystemTime::now()) { - Ok(moved) => { - banner.set_revealed(false); - toasts.add_toast(adw::Toast::new(&crate::ui::connection_list_banner::reset_toast_text( - &moved, - ))); - sender_for_reset.input(AppMsg::ReloadConnections); - } - Err(error) => { - tracing::warn!(%error, "could not reset the saved connections"); - toasts.add_toast(adw::Toast::new(&crate::i18n::gettext_f( - "Could not reset the saved connections: {error}", - &[("error", &error.to_string())], - ))); - } - } + let store = store.clone(); + let resetting = + tasks.spawn_blocking_task(move || store.reset_unreadable_blocking(std::time::SystemTime::now())); + let (banner, toasts, sender) = (banner.clone(), toasts.clone(), sender.clone()); + glib::spawn_future_local(async move { + let error = match resetting.await { + Ok(Ok(moved)) => { + banner.set_revealed(false); + toasts.add_toast(adw::Toast::new(&crate::ui::connection_list_banner::reset_toast_text( + &moved, + ))); + sender.input(AppMsg::ReloadConnections); + return; + } + Ok(Err(error)) => error.to_string(), + Err(error) => error.to_string(), + }; + tracing::warn!(%error, "could not reset the saved connections"); + toasts.add_toast(adw::Toast::new(&crate::i18n::gettext_f( + "Could not reset the saved connections: {error}", + &[("error", &error)], + ))); + }); }); dialog.present(Some(&self.window)); } } +fn ask_where_to_export(window: &adw::ApplicationWindow, bytes: Vec, input: relm4::Sender) { + let dialog = gtk::FileDialog::builder() + .title(crate::i18n::gettext("Export Connections")) + .modal(true) + .initial_name("tablepro-connections.json") + .default_filter(&json_filter()) + .filters(&json_filters()) + .build(); + dialog.save(Some(window), gio::Cancellable::NONE, move |outcome| { + let Ok(file) = outcome else { return }; + glib::spawn_future_local(async move { + let written = file + .replace_contents_future(bytes, None, false, gio::FileCreateFlags::NONE) + .await; + let message = match written { + Ok(_) => crate::i18n::gettext("Connections exported."), + Err((_, error)) => { + tracing::warn!(%error, uri = %file.uri(), "could not write the exported connections"); + crate::i18n::gettext("The connections could not be written to that file.") + } + }; + let _ = input.send(AppMsg::ShowToast(message)); + }); + }); +} + /// An export holds names, hosts and options, never secrets, so even /// thousands of connections come to well under this. const MAX_CONNECTIONS_FILE_BYTES: usize = 8 * 1024 * 1024; From 9393ef2c8a816f6deaa1a49b4763b5f42f09d4dd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:03:27 +0700 Subject: [PATCH 119/178] refactor(linux): drop the unused nullability_switch DDL capability --- linux/crates/core/src/ddl/ddl_capabilities.rs | 3 --- linux/crates/core/src/ddl/tests.rs | 1 - linux/crates/drivers/clickhouse/src/ddl.rs | 4 +--- linux/crates/drivers/mssql/src/ddl.rs | 1 - linux/crates/drivers/mysql/src/ddl.rs | 1 - linux/crates/drivers/postgres/src/ddl.rs | 1 - linux/crates/drivers/sqlite/src/ddl.rs | 1 - 7 files changed, 1 insertion(+), 11 deletions(-) diff --git a/linux/crates/core/src/ddl/ddl_capabilities.rs b/linux/crates/core/src/ddl/ddl_capabilities.rs index b77eff89cf..0d2459d207 100644 --- a/linux/crates/core/src/ddl/ddl_capabilities.rs +++ b/linux/crates/core/src/ddl/ddl_capabilities.rs @@ -9,9 +9,6 @@ pub struct DdlCapabilities { pub rename_column: bool, pub alter_column_type: bool, pub alter_column_nullability: bool, - /// Whether nullability can be switched in place. ClickHouse can - /// only do it by rewriting the column's type as Nullable. - pub nullability_switch: bool, pub alter_column_default: bool, pub alter_primary_key: bool, pub alter_auto_increment: bool, diff --git a/linux/crates/core/src/ddl/tests.rs b/linux/crates/core/src/ddl/tests.rs index c3a32928c4..26eaed075d 100644 --- a/linux/crates/core/src/ddl/tests.rs +++ b/linux/crates/core/src/ddl/tests.rs @@ -337,7 +337,6 @@ fn every_change() -> DdlCapabilities { rename_column: true, alter_column_type: true, alter_column_nullability: true, - nullability_switch: true, alter_column_default: true, alter_primary_key: true, alter_auto_increment: true, diff --git a/linux/crates/drivers/clickhouse/src/ddl.rs b/linux/crates/drivers/clickhouse/src/ddl.rs index 8b7093f806..4f305fb832 100644 --- a/linux/crates/drivers/clickhouse/src/ddl.rs +++ b/linux/crates/drivers/clickhouse/src/ddl.rs @@ -18,10 +18,9 @@ static CAPABILITIES: DdlCapabilities = DdlCapabilities { drop_column: true, rename_column: true, alter_column_type: true, - alter_column_nullability: true, // Nullability is part of the type, so it changes by rewriting the // column as Nullable(T) or T. - nullability_switch: false, + alter_column_nullability: true, alter_column_default: true, alter_primary_key: false, alter_auto_increment: false, @@ -298,7 +297,6 @@ mod tests { assert_eq!(plain.rename, Permission::Allowed); assert_eq!(plain.alter_type, Permission::Allowed); assert!(!DdlDialect::capabilities(&dialect()).create_table); - assert!(!DdlDialect::capabilities(&dialect()).nullability_switch); assert!(matches!( alter(&table, |draft| draft.columns[0].name = "event_id".to_owned()), Err(BuildDdlError::Refused { diff --git a/linux/crates/drivers/mssql/src/ddl.rs b/linux/crates/drivers/mssql/src/ddl.rs index b554da2d95..52697f66f4 100644 --- a/linux/crates/drivers/mssql/src/ddl.rs +++ b/linux/crates/drivers/mssql/src/ddl.rs @@ -18,7 +18,6 @@ static CAPABILITIES: DdlCapabilities = DdlCapabilities { rename_column: true, alter_column_type: true, alter_column_nullability: true, - nullability_switch: true, alter_column_default: true, alter_primary_key: true, // IDENTITY cannot be added to or taken from an existing column. diff --git a/linux/crates/drivers/mysql/src/ddl.rs b/linux/crates/drivers/mysql/src/ddl.rs index e92502beff..33732f7d7d 100644 --- a/linux/crates/drivers/mysql/src/ddl.rs +++ b/linux/crates/drivers/mysql/src/ddl.rs @@ -20,7 +20,6 @@ const fn capabilities(expression_index_keys: bool) -> DdlCapabilities { rename_column: true, alter_column_type: true, alter_column_nullability: true, - nullability_switch: true, alter_column_default: true, alter_primary_key: true, alter_auto_increment: true, diff --git a/linux/crates/drivers/postgres/src/ddl.rs b/linux/crates/drivers/postgres/src/ddl.rs index 684d9672b8..e6f4ac16fe 100644 --- a/linux/crates/drivers/postgres/src/ddl.rs +++ b/linux/crates/drivers/postgres/src/ddl.rs @@ -18,7 +18,6 @@ static CAPABILITIES: DdlCapabilities = DdlCapabilities { rename_column: true, alter_column_type: true, alter_column_nullability: true, - nullability_switch: true, alter_column_default: true, alter_primary_key: true, // An identity added to a column with rows has to start past them, diff --git a/linux/crates/drivers/sqlite/src/ddl.rs b/linux/crates/drivers/sqlite/src/ddl.rs index b8ba97eaa1..5ff65e97bf 100644 --- a/linux/crates/drivers/sqlite/src/ddl.rs +++ b/linux/crates/drivers/sqlite/src/ddl.rs @@ -20,7 +20,6 @@ static CAPABILITIES: DdlCapabilities = DdlCapabilities { rename_column: true, alter_column_type: false, alter_column_nullability: false, - nullability_switch: false, alter_column_default: false, alter_primary_key: false, alter_auto_increment: false, From 55f5ed9b8dcc89286d24fad0c30944bdc15f0fea Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:03:34 +0700 Subject: [PATCH 120/178] fix(linux): save SQLite tables keyed by INTEGER PRIMARY KEY --- linux/CHANGELOG.md | 1 + linux/crates/core/src/ddl/table_draft.rs | 10 +++++++++- linux/crates/core/src/ddl/tests.rs | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index be2fd1172c..ce2239440a 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -58,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Turning a column's primary key on or off in the Structure tab saved nothing. - Dropping a SQL Server column that has a default failed. - Creating a SQLite table with a foreign key failed. +- The Structure tab refused every save on a SQLite table keyed by INTEGER PRIMARY KEY. ### Security diff --git a/linux/crates/core/src/ddl/table_draft.rs b/linux/crates/core/src/ddl/table_draft.rs index 545799af0e..4fea7675c3 100644 --- a/linux/crates/core/src/ddl/table_draft.rs +++ b/linux/crates/core/src/ddl/table_draft.rs @@ -73,7 +73,7 @@ impl TableDraft { if column.column_type.text().is_empty() { return Err(DraftProblem::EmptyColumnType(column.name.clone())); } - if column.primary_key && column.nullable { + if column.primary_key && column.nullable && key_or_nullability_edited(column) { return Err(DraftProblem::NullablePrimaryKey(column.name.clone())); } } @@ -147,3 +147,11 @@ impl TableDraft { } } } + +/// A key read back as it is stays the server's business: SQLite reports +/// an `INTEGER PRIMARY KEY` as taking NULL. +fn key_or_nullability_edited(column: &DraftColumn) -> bool { + column.original.as_ref().is_none_or(|original| { + original.info.primary_key != column.primary_key || original.info.nullable != column.nullable + }) +} diff --git a/linux/crates/core/src/ddl/tests.rs b/linux/crates/core/src/ddl/tests.rs index 26eaed075d..fc2dc5046a 100644 --- a/linux/crates/core/src/ddl/tests.rs +++ b/linux/crates/core/src/ddl/tests.rs @@ -319,6 +319,22 @@ fn table_draft_validate_reports_the_first_problem() { assert_eq!(draft.validate(), Ok(())); } +#[test] +fn a_nullable_key_read_back_as_it_is_saves() { + let mut table = users(); + // How SQLite reports an INTEGER PRIMARY KEY. + table.columns[0].info.nullable = true; + let mut draft = TableDraft::from_definition(&table); + + assert_eq!(draft.validate(), Ok(())); + + draft_column(&mut draft, "email").primary_key = true; + assert_eq!( + draft.validate(), + Err(DraftProblem::NullablePrimaryKey("email".to_owned())) + ); +} + fn inbound(name: &str, referenced: &str) -> InboundForeignKey { InboundForeignKey { name: name.to_owned(), From 0bbac45603efa777b5090d12d3852e0735212ccd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:03:34 +0700 Subject: [PATCH 121/178] fix(linux): script a PostgreSQL serial column as serial --- linux/crates/drivers/postgres/src/ddl.rs | 56 ++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/linux/crates/drivers/postgres/src/ddl.rs b/linux/crates/drivers/postgres/src/ddl.rs index e6f4ac16fe..16419be5c9 100644 --- a/linux/crates/drivers/postgres/src/ddl.rs +++ b/linux/crates/drivers/postgres/src/ddl.rs @@ -1,4 +1,4 @@ -use tablepro_core::column::ColumnType; +use tablepro_core::column::{ColumnKind, ColumnType, IntegerKind, IntegerWidth}; use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, default_sql, foreign_key_sql}; use tablepro_core::ddl::{ BuildDdlError, CollationName, DdlCapabilities, DdlDialect, DdlPlan, DraftColumn, GeneratedStorage, IdentityStyle, @@ -218,7 +218,14 @@ impl PgDialect { fn column_definition(&self, column: &DraftColumn) -> Result { let resolved = column.column_type.resolve(self, &column.name)?; let attributes = column.original.as_ref().map(|original| &original.attributes); - let mut parts = vec![self.quote_identifier(&column.name), resolved.sql.clone()]; + // A serial column numbers itself through a sequence default, and + // only its pseudo-type makes one again: an identity clause would + // be another kind of column. + let serial = attributes + .filter(|attributes| column.auto_increment && attributes.identity == IdentityStyle::SequenceDefault) + .and_then(|_| serial_type(&resolved.column_type)); + let declaration = serial.map_or_else(|| resolved.sql.clone(), str::to_owned); + let mut parts = vec![self.quote_identifier(&column.name), declaration]; if let Some(collation) = attributes.and_then(|attributes| attributes.collation.as_ref()) { parts.push(format!("COLLATE {}", self.collation(collation))); } @@ -228,13 +235,15 @@ impl PgDialect { GeneratedStorage::Virtual => "VIRTUAL", }; parts.push(format!("GENERATED ALWAYS AS ({}) {storage}", generated.expression)); - } else if column.auto_increment { + } else if column.auto_increment && serial.is_none() { let always = attributes.is_some_and(|attributes| attributes.identity == IdentityStyle::GeneratedAlways); parts.push(match always { true => "GENERATED ALWAYS AS IDENTITY".to_owned(), false => "GENERATED BY DEFAULT AS IDENTITY".to_owned(), }); - } else if let Some(default) = default_sql(self, &column.name, &column.default, &resolved.column_type)? { + } else if serial.is_none() + && let Some(default) = default_sql(self, &column.name, &column.default, &resolved.column_type)? + { parts.push(format!("DEFAULT {default}")); } if !column.nullable { @@ -264,6 +273,24 @@ impl PgDialect { } } +fn serial_type(column_type: &ColumnType) -> Option<&'static str> { + match &column_type.kind { + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W16, + .. + }) => Some("smallserial"), + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + .. + }) => Some("serial"), + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W64, + .. + }) => Some("bigserial"), + _ => None, + } +} + #[cfg(test)] mod tests { use tablepro_core::ForeignKeyInfo; @@ -363,6 +390,27 @@ mod tests { ); } + #[test] + fn the_script_keeps_serial_and_identity_columns_apart() { + let mut table = users(); + let numbered = |column: &mut ColumnDefinition, identity: IdentityStyle| { + column.info.is_auto_increment = true; + column.attributes.identity = identity; + }; + numbered(&mut table.columns[0], IdentityStyle::SequenceDefault); + let mut ticket = column("ticket", "bigint", false); + numbered(&mut ticket, IdentityStyle::GeneratedAlways); + table.columns.push(ticket); + + assert_eq!( + sql(dialect().create_table_script(&table)), + [ + "CREATE TABLE \"app\".\"users\" (\n \"id\" serial NOT NULL,\n \"email\" text,\n \"status\" \ + integer,\n \"ticket\" bigint GENERATED ALWAYS AS IDENTITY,\n PRIMARY KEY (\"id\")\n)" + ] + ); + } + #[test] fn plan_create_table_composite_key_index_and_foreign_key() { let mut draft = TableDraft::new(TableRef::unqualified("lines")); From 9fb638a6bb72e70e25d0af8e87feb9b4e51ff394 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:03:34 +0700 Subject: [PATCH 122/178] fix(linux): restate MySQL column attributes and gate comment edits --- linux/CHANGELOG.md | 1 + .../app/src/ui/structure_tab/columns.rs | 9 +- linux/crates/app/src/ui/structure_tab/mod.rs | 2 +- .../crates/app/src/ui/structure_tab/tests.rs | 34 +++ .../core/src/ddl/column_capabilities.rs | 54 +++-- linux/crates/core/src/ddl/table_changes.rs | 8 +- linux/crates/core/src/ddl/tests.rs | 1 + linux/crates/drivers/mysql/src/ddl.rs | 203 ++++++++++++++++-- .../drivers/mysql/tests/integration/ddl.rs | 123 +++++++++++ .../drivers/mysql/tests/integration/main.rs | 1 + linux/docs/adding-drivers.md | 4 +- .../0010-ddl-plans-and-table-definitions.md | 4 +- 12 files changed, 394 insertions(+), 50 deletions(-) create mode 100644 linux/crates/drivers/mysql/tests/integration/ddl.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index ce2239440a..c93b14f393 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -58,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Turning a column's primary key on or off in the Structure tab saved nothing. - Dropping a SQL Server column that has a default failed. - Creating a SQLite table with a foreign key failed. +- Changing a MySQL column in the Structure tab dropped its collation, ON UPDATE, INVISIBLE and SRID, and a comment turned a generated column into a plain one. - The Structure tab refused every save on a SQLite table keyed by INTEGER PRIMARY KEY. ### Security diff --git a/linux/crates/app/src/ui/structure_tab/columns.rs b/linux/crates/app/src/ui/structure_tab/columns.rs index 7411191956..3b453eaabd 100644 --- a/linux/crates/app/src/ui/structure_tab/columns.rs +++ b/linux/crates/app/src/ui/structure_tab/columns.rs @@ -19,7 +19,6 @@ use super::{ColumnField, StructureTabInput, StructureTabSender}; pub(super) struct ColumnRowContext { pub permissions: ColumnCapabilities, pub type_suggestions: &'static [&'static str], - pub column_comments: bool, pub dialect: Option>, /// The default as SQL the user could type back in, or the text they /// typed that did not parse. @@ -277,13 +276,7 @@ pub(super) fn build_column_expander_row( let comment_row = adw::EntryRow::builder().title(crate::i18n::gettext("Comment")).build(); comment_row.set_text(col.comment.as_deref().unwrap_or("")); comment_row.set_widget_name(&format!("col-comment-{index}")); - gate( - &comment_row, - &Permission::unless( - context.column_comments, - tablepro_core::ddl::RefusalReason::EngineUnsupported, - ), - ); + gate(&comment_row, &permissions.alter_comment); let sender_for_comment = sender; let suppress_for_comment = suppress_emit; comment_row.connect_changed(move |e| { diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index d67707dbb2..b378a6c46b 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -601,6 +601,7 @@ impl StructureModel { alter_default: Permission::Allowed, alter_primary_key: primary_key, alter_auto_increment: Permission::Allowed, + alter_comment: Permission::unless(capabilities.column_comments, RefusalReason::EngineUnsupported), }; }; ddl.column_capabilities(definition, original) @@ -638,7 +639,6 @@ impl StructureModel { let context = ColumnRowContext { permissions: self.column_permissions(col), type_suggestions, - column_comments: capabilities.column_comments, dialect: self.dialect.clone(), default_text, default_error, diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index acde3e553f..eb6eaf7a2d 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -430,6 +430,40 @@ fn referenced_primary_key_switch_is_insensitive_with_foreign_key_names() { assert!(named_widget(harness.tab(), "col-remove-1").is_sensitive()); } +#[gtk4::test] +fn a_computed_column_takes_no_comment_where_the_comment_restates_it() { + let mysql: Arc = Arc::new(drivers_mysql::MySqlDialect::new( + drivers_mysql::MySqlFlavour::MySql, + &ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + )); + let mut table = definition("total"); + table.columns[0].info.column_type = mysql.parse_type("int").expect("a MySQL type"); + table.columns[0].info.is_generated = true; + let mut plain = definition("base").columns.remove(0); + plain.info.column_type = mysql.parse_type("int").expect("a MySQL type"); + table.columns.push(plain); + let mut script = ConnectionScript { + dialect: mysql, + ..ConnectionScript::default() + }; + script.fetch_table_definition.always(FakeReply::ok(table)); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + drain_main_context(); + + let computed = named_widget(harness.tab(), "col-comment-0"); + + assert!(!computed.is_sensitive(), "a generated column's comment could be set"); + assert!( + computed + .tooltip_text() + .is_some_and(|tip| tip.contains("computes this column")), + "{:?}", + computed.tooltip_text() + ); + assert!(named_widget(harness.tab(), "col-comment-1").is_sensitive()); +} + #[gtk4::test] fn an_edit_previews_the_plan_and_saves_through_the_tracker() { let mut script = postgres_script(); diff --git a/linux/crates/core/src/ddl/column_capabilities.rs b/linux/crates/core/src/ddl/column_capabilities.rs index 94b396c676..a9b4cd79a9 100644 --- a/linux/crates/core/src/ddl/column_capabilities.rs +++ b/linux/crates/core/src/ddl/column_capabilities.rs @@ -11,6 +11,7 @@ pub struct ColumnCapabilities { pub alter_default: Permission, pub alter_primary_key: Permission, pub alter_auto_increment: Permission, + pub alter_comment: Permission, } impl ColumnCapabilities { @@ -18,22 +19,15 @@ impl ColumnCapabilities { /// restatement that would drop a generated expression or an /// attribute this build cannot restate, a default on a numbered /// column, and anything foreign keys elsewhere depend on. + /// + /// A comment is set apart from the definition on most engines, so + /// only an engine that restates the column for it refuses it here. pub fn standard(capabilities: &DdlCapabilities, table: &TableDefinition, column: &ColumnDefinition) -> Self { - let attributes = &column.attributes; let engine = |allowed: bool| Permission::unless(allowed, RefusalReason::EngineUnsupported); - let not_generated = || { - Permission::unless( - !column.info.is_generated && attributes.generated.is_none(), - RefusalReason::GeneratedColumn, - ) - }; - let modeled = || match attributes.unmodeled.first() { - Some(attribute) => Permission::Refused(RefusalReason::UnmodeledAttribute(attribute.name.clone())), - None => Permission::Allowed, - }; + let restatement = || Self::restatement(column); let not_numbered = || { Permission::unless( - attributes.identity == IdentityStyle::None && !column.info.is_auto_increment, + column.attributes.identity == IdentityStyle::None && !column.info.is_auto_increment, RefusalReason::IdentityColumn, ) }; @@ -51,20 +45,38 @@ impl ColumnCapabilities { RefusalReason::ReferencedByForeignKeys(referencing), ) }), - alter_type: engine(capabilities.alter_column_type).and(not_generated).and(modeled), - alter_nullability: engine(capabilities.alter_column_nullability) - .and(not_generated) - .and(modeled), + alter_type: engine(capabilities.alter_column_type).and(restatement), + alter_nullability: engine(capabilities.alter_column_nullability).and(restatement), alter_default: engine(capabilities.alter_column_default) - .and(not_generated) + .and(|| not_generated(column)) .and(not_numbered) - .and(modeled), + .and(|| modeled(column)), alter_primary_key: primary_key_permission(capabilities, table), - alter_auto_increment: engine(capabilities.alter_auto_increment) - .and(not_generated) - .and(modeled), + alter_auto_increment: engine(capabilities.alter_auto_increment).and(restatement), + alter_comment: engine(capabilities.column_comments), } } + + /// Whether the column can be written out again whole: a generated + /// one would lose its expression, and one with an attribute this + /// build does not model would lose that. + pub fn restatement(column: &ColumnDefinition) -> Permission { + not_generated(column).and(|| modeled(column)) + } +} + +fn not_generated(column: &ColumnDefinition) -> Permission { + Permission::unless( + !column.info.is_generated && column.attributes.generated.is_none(), + RefusalReason::GeneratedColumn, + ) +} + +fn modeled(column: &ColumnDefinition) -> Permission { + match column.attributes.unmodeled.first() { + Some(attribute) => Permission::Refused(RefusalReason::UnmodeledAttribute(attribute.name.clone())), + None => Permission::Allowed, + } } /// Whether the table's primary key can move. A foreign key elsewhere diff --git a/linux/crates/core/src/ddl/table_changes.rs b/linux/crates/core/src/ddl/table_changes.rs index 701f6ff7fc..3cdcf46922 100644 --- a/linux/crates/core/src/ddl/table_changes.rs +++ b/linux/crates/core/src/ddl/table_changes.rs @@ -98,15 +98,17 @@ impl TableChanges { permissions.alter_auto_increment, DdlOperation::AlterAutoIncrement, ), + ( + changed.comment, + permissions.alter_comment, + DdlOperation::SetColumnComment, + ), ]; for (changed, permission, operation) in checks { if changed { permit(permission, name, operation)?; } } - if changed.comment { - require(capabilities.column_comments, DdlOperation::SetColumnComment)?; - } } for column in &self.added_columns { require(capabilities.add_column, DdlOperation::AddColumn)?; diff --git a/linux/crates/core/src/ddl/tests.rs b/linux/crates/core/src/ddl/tests.rs index fc2dc5046a..59f0363769 100644 --- a/linux/crates/core/src/ddl/tests.rs +++ b/linux/crates/core/src/ddl/tests.rs @@ -431,6 +431,7 @@ fn generated_numbered_and_unmodeled_columns_refuse_a_restatement() { Permission::Refused(RefusalReason::GeneratedColumn) ); assert_eq!(generated.rename, Permission::Allowed); + assert_eq!(generated.alter_comment, Permission::Allowed); assert_eq!( numbered.alter_default, Permission::Refused(RefusalReason::IdentityColumn) diff --git a/linux/crates/drivers/mysql/src/ddl.rs b/linux/crates/drivers/mysql/src/ddl.rs index 33732f7d7d..28396dd6b6 100644 --- a/linux/crates/drivers/mysql/src/ddl.rs +++ b/linux/crates/drivers/mysql/src/ddl.rs @@ -1,10 +1,11 @@ -use tablepro_core::column::ColumnDefault; +use tablepro_core::column::{ColumnDefault, ColumnKind}; use tablepro_core::ddl::ddl_sql::{ column_list, create_index_sql, default_sql, foreign_key_sql, index_keys_sql, parenthesized, }; use tablepro_core::ddl::{ - BuildDdlError, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlPlan, DraftColumn, Permission, - ReferentialAction, RefusalReason, TableChanges, TableDefinition, TableDraft, + BuildDdlError, ColumnAttributes, ColumnCapabilities, ColumnDefinition, ColumnVisibility, DdlCapabilities, + DdlDialect, DdlPlan, DraftColumn, DraftType, Permission, ReferentialAction, RefusalReason, TableChanges, + TableDefinition, TableDraft, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; @@ -84,15 +85,20 @@ impl DdlDialect for MySqlDialect { self } - /// A MODIFY restates the type, so a column whose type the catalogue - /// did not spell cannot take one. + /// A comment is part of the definition MODIFY restates, so it takes + /// what a restatement takes. A MODIFY restates the type too, so a + /// column whose type the catalogue did not spell cannot take one. fn column_capabilities(&self, table: &TableDefinition, column: &ColumnDefinition) -> ColumnCapabilities { let mut permissions = ColumnCapabilities::standard(DdlDialect::capabilities(self), table, column); + permissions.alter_comment = permissions + .alter_comment + .and(|| ColumnCapabilities::restatement(column)); if column.info.column_type.catalog.is_none() { let unreadable = || Permission::Refused(RefusalReason::UnreadableDefinition); permissions.alter_nullability = permissions.alter_nullability.and(unreadable); permissions.alter_default = permissions.alter_default.and(unreadable); permissions.alter_auto_increment = permissions.alter_auto_increment.and(unreadable); + permissions.alter_comment = permissions.alter_comment.and(unreadable); } permissions } @@ -228,12 +234,14 @@ impl DdlDialect for MySqlDialect { } /// The server's own SHOW CREATE TABLE, which carries the engine, - /// charset and every option the definition does not model. + /// charset and every option the definition does not model, and + /// spells a comment for whatever sql_mode runs it. fn create_table_script(&self, definition: &TableDefinition) -> Result { - match &definition.source { - Some(source) => Ok(DdlPlan::from_sql([source.create_statement.clone()])), - None => self.plan_create_table(&TableDraft::from_definition(definition)), - } + definition + .source + .as_ref() + .map(|source| DdlPlan::from_sql([source.create_statement.clone()])) + .ok_or(BuildDdlError::NoTableSource) } } @@ -244,11 +252,27 @@ fn restates(changed: tablepro_core::ddl::ChangedAttributes) -> bool { } impl MySqlDialect { - /// `name type NULL|NOT NULL [DEFAULT ..] [AUTO_INCREMENT] [COMMENT ..]`, - /// the whole definition MODIFY replaces the old one with. + /// The whole definition MODIFY or CHANGE replaces the old one with: + /// an attribute it leaves out is gone, so every one the column had + /// that still fits its type is written again. fn column_definition(&self, column: &DraftColumn) -> Result { let resolved = column.column_type.resolve(self, &column.name)?; + let kind = &resolved.column_type.kind; + let added = ColumnAttributes::default(); + let kept = column.original.as_ref().map_or(&added, |original| &original.attributes); let mut parts = vec![self.quote_identifier(&column.name), resolved.sql.clone()]; + // The catalogue names a character set only for a column that + // holds text, and a type the user typed may not. + let textual = matches!(column.column_type, DraftType::Catalog(_)) + || matches!(kind, ColumnKind::Text { .. } | ColumnKind::Enum { .. }); + if textual { + if let Some(character_set) = &kept.character_set { + parts.push(format!("CHARACTER SET {}", self.quote_identifier(character_set))); + } + if let Some(collation) = &kept.collation { + parts.push(format!("COLLATE {}", self.quote_identifier(&collation.name))); + } + } parts.push(if column.nullable { "NULL" } else { "NOT NULL" }.to_owned()); if !column.auto_increment && let Some(default) = default_sql(self, &column.name, &column.default, &resolved.column_type)? @@ -258,9 +282,22 @@ impl MySqlDialect { default_clause(&column.default, &default, &resolved.sql) )); } + if let Some(expression) = kept + .on_update + .as_ref() + .filter(|_| matches!(kind, ColumnKind::Timestamp { .. })) + { + parts.push(format!("ON UPDATE {}", expression.as_sql())); + } if column.auto_increment { parts.push("AUTO_INCREMENT".to_owned()); } + if kept.visibility == ColumnVisibility::Invisible { + parts.push("INVISIBLE".to_owned()); + } + if let Some(srid) = kept.spatial_reference.filter(|_| matches!(kind, ColumnKind::Spatial)) { + parts.push(format!("SRID {srid}")); + } if let Some(comment) = column.comment_text() { parts.push(format!("COMMENT {}", comment_literal(comment))); } @@ -339,8 +376,8 @@ mod tests { use tablepro_core::ServerInfo; use tablepro_core::column::{ColumnInfo, SqlExpression}; use tablepro_core::ddl::{ - ColumnAttributes, DdlOperation, DraftType, DropTableOptions, InboundForeignKey, PrimaryKeyConstraint, - diff_table, + CollationName, DdlOperation, DropTableOptions, GeneratedColumn, GeneratedStorage, InboundForeignKey, + PrimaryKeyConstraint, UnmodeledAttribute, diff_table, }; use tablepro_core::meta::{IndexInfo, IndexKey}; use tablepro_core::value::Value; @@ -632,6 +669,144 @@ mod tests { sql(dialect().plan_drop_table(&table.table, DropTableOptions::default())), ["DROP TABLE `app`.`users`"] ); + assert_eq!( + dialect().create_table_script(&users()), + Err(BuildDdlError::NoTableSource) + ); + } + + fn with_attributes(name: &str, declaration: &str, edit: impl FnOnce(&mut ColumnAttributes)) -> ColumnDefinition { + let mut column = column(name, declaration, false); + edit(&mut column.attributes); + column + } + + fn attributed() -> TableDefinition { + let mut table = users(); + table.columns.extend([ + with_attributes("code", "varchar(20)", |attributes| { + attributes.character_set = Some("utf8mb4".to_owned()); + attributes.collation = Some(CollationName::new(None, "utf8mb4_bin")); + }), + with_attributes("stamp", "timestamp", |attributes| { + attributes.on_update = Some(SqlExpression::from_catalog_text("CURRENT_TIMESTAMP")); + attributes.visibility = ColumnVisibility::Invisible; + }), + with_attributes("shape", "point", |attributes| attributes.spatial_reference = Some(4326)), + ]); + table + } + + fn edit_column(draft: &mut TableDraft, name: &str, edit: impl FnOnce(&mut DraftColumn)) { + edit( + draft + .columns + .iter_mut() + .find(|column| column.name == name) + .expect("the column"), + ); + } + + #[test] + fn a_restatement_keeps_charset_collation_on_update_invisible_and_srid() { + let plan = alter(&attributed(), |draft| { + edit_column(draft, "code", |column| column.nullable = false); + edit_column(draft, "stamp", |column| column.comment = Some("touched".to_owned())); + edit_column(draft, "shape", |column| column.nullable = false); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE `app`.`users` MODIFY COLUMN `code` varchar(20) CHARACTER SET `utf8mb4` COLLATE \ + `utf8mb4_bin` NOT NULL, MODIFY COLUMN `stamp` timestamp NULL ON UPDATE CURRENT_TIMESTAMP INVISIBLE \ + COMMENT 'touched', MODIFY COLUMN `shape` point NOT NULL SRID 4326" + ] + ); + } + + #[test] + fn a_new_type_drops_the_attributes_it_cannot_hold() { + let plan = alter(&attributed(), |draft| { + edit_column(draft, "code", |column| { + column.column_type = DraftType::Typed("int".to_owned()) + }); + edit_column(draft, "stamp", |column| { + column.column_type = DraftType::Typed("date".to_owned()) + }); + }); + + assert_eq!( + sql(plan), + ["ALTER TABLE `app`.`users` MODIFY COLUMN `code` int NULL, MODIFY COLUMN `stamp` date NULL INVISIBLE"] + ); + // A character set typed with the type would clash with the one + // kept from the catalogue, so the type parser has to refuse it. + assert!(dialect().parse_type("varchar(20) character set latin1").is_err()); + assert!(dialect().parse_type("varchar(20) collate latin1_bin").is_err()); + } + + #[test] + fn a_comment_is_refused_where_a_restatement_is() { + let mut table = users(); + table.columns.extend([ + with_attributes("total", "int", |attributes| { + attributes.generated = Some(GeneratedColumn { + expression: SqlExpression::from_catalog_text("`id` * 2"), + storage: GeneratedStorage::Stored, + }); + }), + with_attributes("note", "varchar(20)", |attributes| { + attributes.unmodeled = vec![UnmodeledAttribute::new("WITHOUT SYSTEM VERSIONING")]; + }), + ]); + table.columns[3].info.is_generated = true; + + let generated = dialect().column_capabilities(&table, &table.columns[3]); + let unmodeled = dialect().column_capabilities(&table, &table.columns[4]); + let commented = alter(&table, |draft| { + edit_column(draft, "total", |column| column.comment = Some("x".to_owned())); + }); + let renamed_and_commented = alter(&table, |draft| { + edit_column(draft, "note", |column| { + column.name = "remark".to_owned(); + column.comment = Some("x".to_owned()); + }); + }); + + assert_eq!( + generated.alter_comment, + Permission::Refused(RefusalReason::GeneratedColumn) + ); + assert_eq!( + unmodeled.alter_comment, + Permission::Refused(RefusalReason::UnmodeledAttribute( + "WITHOUT SYSTEM VERSIONING".to_owned() + )) + ); + assert_eq!(generated.rename, Permission::Allowed); + assert!( + matches!( + commented, + Err(BuildDdlError::Refused { + operation: DdlOperation::SetColumnComment, + reason: RefusalReason::GeneratedColumn, + .. + }) + ), + "{commented:?}" + ); + assert!( + matches!( + renamed_and_commented, + Err(BuildDdlError::Refused { + operation: DdlOperation::SetColumnComment, + reason: RefusalReason::UnmodeledAttribute(_), + .. + }) + ), + "{renamed_and_commented:?}" + ); } #[test] diff --git a/linux/crates/drivers/mysql/tests/integration/ddl.rs b/linux/crates/drivers/mysql/tests/integration/ddl.rs new file mode 100644 index 0000000000..dbe701d734 --- /dev/null +++ b/linux/crates/drivers/mysql/tests/integration/ddl.rs @@ -0,0 +1,123 @@ +//! Structure changes the MySQL dialect plans, run against both servers. + +use tablepro_core::Connection; +use tablepro_core::ddl::{ + BuildDdlError, ColumnVisibility, DdlOperation, RefusalReason, TableDefinition, TableDraft, diff_table, +}; +use tablepro_test_fixtures::MysqlFlavour; + +use crate::support::{CALL, FLAVOURS, connect, setup, start, table, within}; + +async fn read(connection: &dyn Connection) -> TableDefinition { + connection + .fetch_table_definition(&table("kept"), within(CALL)) + .await + .expect("the definition") +} + +fn edit( + definition: &TableDefinition, + name: &str, + change: impl FnOnce(&mut tablepro_core::ddl::DraftColumn), +) -> TableDraft { + let mut draft = TableDraft::from_definition(definition); + if let Some(column) = draft.columns.iter_mut().find(|column| column.name == name) { + change(column); + } + draft +} + +/// MODIFY replaces the whole definition, so each attribute the column +/// had is gone unless the plan writes it again. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_restated_column_keeps_its_modeled_attributes() { + for flavour in FLAVOURS { + let fixture = start(flavour).await; + let connection = connect(&fixture, false).await; + let spatial = match flavour { + MysqlFlavour::Mysql => ", shape point NOT NULL SRID 4326", + MysqlFlavour::Mariadb => "", + }; + setup( + connection.as_ref(), + &[&format!( + "CREATE TABLE kept (\ + id int PRIMARY KEY, \ + code varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL, \ + changed timestamp(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), \ + hidden int NULL INVISIBLE, \ + base int NULL, \ + doubled int GENERATED ALWAYS AS (base * 2) STORED{spatial})" + )], + ) + .await; + let original = read(connection.as_ref()).await; + let dialect = connection.dialect(); + let ddl = dialect.ddl().expect("MySQL plans its DDL"); + + let mut draft = TableDraft::from_definition(&original); + for column in &mut draft.columns { + match column.name.as_str() { + "code" => column.nullable = false, + "changed" | "hidden" | "shape" => column.comment = Some("touched".to_owned()), + _ => {} + } + } + let plan = ddl + .plan_alter_table(&original, &diff_table(&original, &draft)) + .expect("the change plans"); + connection + .write_batch(&plan.into_write_batch(table("kept")), within(CALL)) + .await + .expect("the change runs"); + let after = read(connection.as_ref()).await; + + let code = after.column("code").expect("code"); + assert!(!code.info.nullable, "{flavour:?}"); + assert_eq!( + code.attributes + .collation + .as_ref() + .map(|collation| collation.name.as_str()), + Some("utf8mb4_bin"), + "{flavour:?}" + ); + let changed = after.column("changed").expect("changed"); + assert_eq!(changed.info.comment.as_deref(), Some("touched"), "{flavour:?}"); + assert!( + changed + .attributes + .on_update + .as_ref() + .is_some_and(|expression| expression + .as_sql() + .to_ascii_lowercase() + .starts_with("current_timestamp")), + "{flavour:?}: {:?}", + changed.attributes.on_update + ); + let hidden = after.column("hidden").expect("hidden"); + assert_eq!(hidden.attributes.visibility, ColumnVisibility::Invisible, "{flavour:?}"); + if flavour == MysqlFlavour::Mysql { + let shape = after.column("shape").expect("shape"); + assert_eq!(shape.attributes.spatial_reference, Some(4326)); + assert_eq!(shape.info.comment.as_deref(), Some("touched")); + } + + let computed = edit(&after, "doubled", |column| column.comment = Some("x".to_owned())); + let refused = ddl.plan_alter_table(&after, &diff_table(&after, &computed)); + assert!( + matches!( + refused, + Err(BuildDdlError::Refused { + operation: DdlOperation::SetColumnComment, + reason: RefusalReason::GeneratedColumn, + .. + }) + ), + "{flavour:?}: {refused:?}" + ); + connection.close().await.expect("a close"); + } +} diff --git a/linux/crates/drivers/mysql/tests/integration/main.rs b/linux/crates/drivers/mysql/tests/integration/main.rs index 3db3ab525d..51a48296eb 100644 --- a/linux/crates/drivers/mysql/tests/integration/main.rs +++ b/linux/crates/drivers/mysql/tests/integration/main.rs @@ -7,6 +7,7 @@ )] mod conformance_engine; +mod ddl; mod decode; mod editor_session; mod flavour_suite; diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index 73532b77e1..daad2fd7c3 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -314,12 +314,12 @@ A run streams `ResultEvent`s into a `ResultSink`: | Method | What it does | |---|---| | `capabilities` | The table-level `DdlCapabilities`. The tab offers only what is on. | -| `column_capabilities` | A `Permission` per change to one column. Start from `ColumnCapabilities::standard`, which refuses a restatement that would drop a generated expression or an unmodeled attribute, a default on a numbered column, a drop of a column another table's foreign key points at, and any primary-key change while a foreign key points at the key. Add the engine's own refusals on top, each with its `RefusalReason`. | +| `column_capabilities` | A `Permission` per change to one column. Start from `ColumnCapabilities::standard`, which refuses a restatement that would drop a generated expression or an unmodeled attribute, a default on a numbered column, a drop of a column another table's foreign key points at, and any primary-key change while a foreign key points at the key. Add the engine's own refusals on top, each with its `RefusalReason`. An engine that writes the whole column again to set its comment, as MySQL does, refuses `alter_comment` where `ColumnCapabilities::restatement` does. | | `type_suggestions`, `default_column_type`, `referential_actions` | What the tab's type menu, new columns and foreign-key dialog offer. Every suggestion has to parse through `parse_type`. | | `plan_create_table(&TableDraft)` | Starts with `draft.check_create(self)?`. Foreign keys go inside CREATE TABLE. | | `plan_alter_table(&TableDefinition, &TableChanges)` | Starts with `changes.check(self, original)?`, then writes the changes in the order the engine needs: one combined ALTER TABLE where the engine takes one, renames first, and every statement after a table rename against the new name. A table rename keeps the table in its schema. | | `plan_drop_table` | The default writes `DROP TABLE [IF EXISTS]
[CASCADE]`. | -| `create_table_script` | The server's own CREATE statement where the engine keeps one; the default writes it from the definition. | +| `create_table_script` | The server's own CREATE statement where the engine keeps one, and `NoTableSource` when the definition carries none; the default writes it from the definition. | Plans quote every name through the dialect, splice a catalogue type unchanged and a typed one only after `parse_type` accepts it, and write a literal default through `SqlDialect::literal`. `tablepro_core::ddl::ddl_sql` has the pieces most engines share. The golden tests for each plan sit in `src/ddl.rs`, and the driver's integration target runs `ddl_conformance_suite!` (see [testing.md](testing.md#ddl-conformance)). diff --git a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md index 88008dbb09..78c792680f 100644 --- a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md +++ b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md @@ -27,7 +27,9 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` - An engine's DDL is one file in its driver crate, and core has no branch on a driver id. - The structure tab's controls follow the capabilities, so a change the engine cannot make is never offered. -- Primary-key identity on PostgreSQL, SQLite table rebuilds and the full restatement of MySQL and SQL Server column attributes are plans of their own, added to the same dialects later. Until then their capabilities are off. +- MySQL writes a column again whole for any change to it, its comment included, so it restates every attribute the definition models: the character set and collation, ON UPDATE, INVISIBLE and SRID where the new type still holds them. It refuses the change on a generated column or one with an attribute this build does not model. +- Primary-key identity on PostgreSQL and SQLite table rebuilds are plans of their own, added to the same dialects later, and their capabilities are off until then. +- The rest of the attribute restatement is not written yet and its changes stay on: a SQL Server ALTER COLUMN does not restate COLLATE or SPARSE, and a PostgreSQL type change does not restate a column's collation, so either change resets them. ## Alternatives considered From c6d127385875d3b069dc041f418fdeeb42a5206c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:03:34 +0700 Subject: [PATCH 123/178] fix(linux): run the Structure tab's checks on every save --- linux/CHANGELOG.md | 1 + .../app/src/services/pending_structure.rs | 106 +++++++++++++++++- .../app/src/services/structure_tracker.rs | 1 + .../services/structure_tracker_registry.rs | 1 + linux/crates/app/src/ui/app/structure.rs | 34 +++--- .../app/src/ui/structure_tab/ddl_text.rs | 10 ++ linux/crates/app/src/ui/structure_tab/mod.rs | 27 ++--- .../crates/app/src/ui/structure_tab/tests.rs | 8 ++ linux/crates/core/src/ddl/ddl_operation.rs | 2 + 9 files changed, 160 insertions(+), 30 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index c93b14f393..fa6bf5c2c7 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -59,6 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dropping a SQL Server column that has a default failed. - Creating a SQLite table with a foreign key failed. - Changing a MySQL column in the Structure tab dropped its collation, ON UPDATE, INVISIBLE and SRID, and a comment turned a generated column into a plain one. +- Save in a Structure tab's close prompt skipped the tab's checks and dropped a default that did not parse. - The Structure tab refused every save on a SQLite table keyed by INTEGER PRIMARY KEY. ### Security diff --git a/linux/crates/app/src/services/pending_structure.rs b/linux/crates/app/src/services/pending_structure.rs index 52dfc1bb63..b45c250b47 100644 --- a/linux/crates/app/src/services/pending_structure.rs +++ b/linux/crates/app/src/services/pending_structure.rs @@ -1,4 +1,6 @@ -use tablepro_core::ddl::{BuildDdlError, DdlDialect, DdlPlan, TableChanges, TableDefinition, TableDraft}; +use tablepro_core::ddl::{ + BuildDdlError, DdlDialect, DdlOperation, DdlPlan, DraftProblem, TableChanges, TableDefinition, TableDraft, +}; use tablepro_core::meta::TableRef; /// What a Structure tab would save: the table as it was read, the @@ -9,9 +11,30 @@ pub struct PendingStructure { pub original: Option, pub draft: TableDraft, pub changes: TableChanges, + /// Columns whose typed default the engine's parser refused. The + /// draft still holds the default from before, so saving it would + /// drop what the user typed without a word. + pub unparsed_defaults: Vec, +} + +/// Why a structure cannot be saved as it stands. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SaveBlocker { + Draft(DraftProblem), + UnparsedDefault(String), } impl PendingStructure { + /// Every save goes through this, the close prompts' included, which + /// never pass the tab's own Save button. + pub fn check(&self) -> Result<(), SaveBlocker> { + self.draft.validate().map_err(SaveBlocker::Draft)?; + match self.unparsed_defaults.first() { + Some(column) => Err(SaveBlocker::UnparsedDefault(column.clone())), + None => Ok(()), + } + } + pub fn has_changes(&self) -> bool { match self.original { Some(_) => !self.changes.is_empty(), @@ -31,11 +54,22 @@ impl PendingStructure { /// the SQL is spelled for that server. pub fn plan(&self, ddl: &dyn DdlDialect) -> Result { match &self.original { - Some(original) => ddl.plan_alter_table(original, &self.changes), + Some(original) => { + self.draft.validate()?; + ddl.plan_alter_table(original, &self.changes) + } None => ddl.plan_create_table(&self.draft), } } + /// What a connection without structure editing refuses. + pub fn operation(&self) -> DdlOperation { + match self.original { + Some(_) => DdlOperation::AlterTable, + None => DdlOperation::CreateTable, + } + } + /// The table the write runs against, named as it is before the save. pub fn table(&self) -> TableRef { match &self.original { @@ -58,7 +92,9 @@ impl PendingStructure { #[cfg(test)] mod tests { use tablepro_core::ServerInfo; - use tablepro_core::ddl::{DraftColumn, DraftType, diff_table}; + use tablepro_core::column::{ColumnDefault, ColumnInfo}; + use tablepro_core::ddl::{ColumnAttributes, ColumnDefinition, DraftColumn, DraftType, diff_table}; + use tablepro_core::dialect::SqlDialect; use super::*; @@ -76,6 +112,7 @@ mod tests { original: None, draft: draft(), changes: TableChanges::default(), + unparsed_defaults: Vec::new(), } } @@ -105,6 +142,67 @@ mod tests { assert_eq!(pending.saved_table_name().as_deref(), Some("orders")); } + fn orders() -> TableDefinition { + let dialect = drivers_postgres::PgDialect::new(&ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + let column = |name: &str, primary_key: bool| { + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type: dialect.parse_type("integer").expect("a PostgreSQL type"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) + }; + TableDefinition { + table: TableRef::new(Some("app".to_owned()), "orders"), + columns: vec![column("id", true), column("total", false)], + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } + } + + fn edited(edit: impl FnOnce(&mut TableDraft)) -> PendingStructure { + let original = orders(); + let mut draft = TableDraft::from_definition(&original); + edit(&mut draft); + PendingStructure { + changes: diff_table(&original, &draft), + original: Some(original), + draft, + unparsed_defaults: Vec::new(), + } + } + + #[test] + fn a_save_waits_for_a_valid_draft_and_every_typed_default() { + let postgres = drivers_postgres::PgDialect::new(&ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + let mut typed_default = edited(|draft| draft.columns[1].nullable = false); + typed_default.unparsed_defaults = vec!["total".to_owned()]; + let unnamed = edited(|draft| draft.columns[1].name = String::new()); + + assert_eq!( + typed_default.check(), + Err(SaveBlocker::UnparsedDefault("total".to_owned())) + ); + assert_eq!(unnamed.check(), Err(SaveBlocker::Draft(DraftProblem::EmptyColumnName))); + assert_eq!( + unnamed.plan(&postgres), + Err(BuildDdlError::Draft(DraftProblem::EmptyColumnName)) + ); + assert_eq!(edited(|draft| draft.columns[1].nullable = false).check(), Ok(())); + assert_eq!(unnamed.operation(), DdlOperation::AlterTable); + assert_eq!(created().operation(), DdlOperation::CreateTable); + } + #[test] fn an_edit_counts_its_changes_and_follows_a_rename() { let original = TableDefinition { @@ -122,11 +220,13 @@ mod tests { changes: diff_table(&original, &renamed), original: Some(original.clone()), draft: renamed, + unparsed_defaults: Vec::new(), }; let unchanged = PendingStructure { changes: TableChanges::default(), draft: TableDraft::from_definition(&original), original: Some(original), + unparsed_defaults: Vec::new(), }; assert!(pending.has_changes()); diff --git a/linux/crates/app/src/services/structure_tracker.rs b/linux/crates/app/src/services/structure_tracker.rs index d6768d6378..9d30067ed6 100644 --- a/linux/crates/app/src/services/structure_tracker.rs +++ b/linux/crates/app/src/services/structure_tracker.rs @@ -48,6 +48,7 @@ mod tests { original: None, draft, changes: TableChanges::default(), + unparsed_defaults: Vec::new(), } } diff --git a/linux/crates/app/src/services/structure_tracker_registry.rs b/linux/crates/app/src/services/structure_tracker_registry.rs index 0753c22aee..77202a6e1c 100644 --- a/linux/crates/app/src/services/structure_tracker_registry.rs +++ b/linux/crates/app/src/services/structure_tracker_registry.rs @@ -27,6 +27,7 @@ mod tests { original: None, draft, changes: TableChanges::default(), + unparsed_defaults: Vec::new(), }) } diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index ea7bda9423..11055b583a 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -26,7 +26,7 @@ use crate::services::from_session::FromSession; use crate::services::pending_structure::PendingStructure; use crate::ui::app::{App, AppMsg, WorkspaceTab}; use crate::ui::error_text; -use crate::ui::structure_tab::{StructureMode, StructureTabInput}; +use crate::ui::structure_tab::{StructureMode, StructureTabInput, save_blocker_text}; impl App { /// Sidebar right-click → "New Table…" or schema-header "+" button. @@ -136,7 +136,7 @@ impl App { cascade: false, }; let origin = Arc::downgrade(&session); - let work = run_schema_plan(session, dropped.clone(), move |ddl, table| { + let work = run_schema_plan(session, dropped.clone(), DdlOperation::DropTable, move |ddl, table| { ddl.plan_drop_table(table, options) }); crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { @@ -165,8 +165,9 @@ impl App { let Some(tracker) = self.structure_trackers.get(tab_id) else { return; }; - match tracker.begin_save() { - Some(pending) => self.run_structure_save(tab_id, pending, sender), + match tracker.begin_save().map(|pending| (pending.check(), pending)) { + Some((Ok(()), pending)) => self.run_structure_save(tab_id, pending, sender), + Some((Err(blocker), _)) => self.refuse_structure_save(tab_id, save_blocker_text(&blocker)), // Nothing to save, so a close waiting on this save can go // ahead. None => sender.input(AppMsg::StructureSaveCompleted { @@ -217,11 +218,7 @@ impl App { return; } let Some(session) = self.context.active.get() else { - self.structure_saves_in_flight.borrow_mut().remove(&tab_id); - sender.input(AppMsg::StructureSaveFailed( - tab_id, - crate::i18n::gettext("No active connection."), - )); + self.refuse_structure_save(tab_id, crate::i18n::gettext("No active connection.")); return; }; // Only a Structure tab hosts the DDL editor, so a save for any @@ -240,7 +237,9 @@ impl App { let metadata = self.context.active.metadata(); let work = async move { let table = pending.table(); - let (statements, written) = run_schema_plan(session, table, move |ddl, _| pending.plan(ddl)).await?; + let operation = pending.operation(); + let (statements, written) = + run_schema_plan(session, table, operation, move |ddl, _| pending.plan(ddl)).await?; // Structure edits change the database too, so the history // shows the DDL that did it and not only editor statements. let ran = super::ran_statements::RanStatements::starting(history, metadata, statements); @@ -356,6 +355,13 @@ impl App { if self.in_flight_saves.get() > 0 { self.in_flight_saves.set(self.in_flight_saves.get() - 1); } + self.refuse_structure_save(tab_id, message); + } + + /// End a save with `message`. A save refused before it ran never + /// counted itself in `in_flight_saves`, so only a failed run gives + /// one back, in `on_structure_save_failed`. + fn refuse_structure_save(&mut self, tab_id: Uuid, message: String) { self.structure_saves_in_flight.borrow_mut().remove(&tab_id); // Match the Browse-side `SaveFailedForTab` handler: a save // that started from the close-with-pending dialog left this @@ -494,10 +500,12 @@ impl App { /// Plan a schema change with the dialect of the connection that runs /// it, and run the plan as one write. The statements come back whether -/// or not the write went through, for the history. +/// or not the write went through, for the history. `operation` is what +/// a connection with no DDL dialect refuses. async fn run_schema_plan

( session: Arc, table: TableRef, + operation: DdlOperation, plan: P, ) -> Result<(Vec, Result), OperationError> where @@ -509,9 +517,7 @@ where let dialect = lease.dialect(); let planned = match dialect.ddl() { Some(ddl) => plan(ddl, &table).map_err(SqlBuildError::from), - None => Err(SqlBuildError::from(BuildDdlError::Unsupported( - DdlOperation::CreateTable, - ))), + None => Err(SqlBuildError::from(BuildDdlError::Unsupported(operation))), }; async move { let plan = planned?; diff --git a/linux/crates/app/src/ui/structure_tab/ddl_text.rs b/linux/crates/app/src/ui/structure_tab/ddl_text.rs index a453062d0a..3672f92bbe 100644 --- a/linux/crates/app/src/ui/structure_tab/ddl_text.rs +++ b/linux/crates/app/src/ui/structure_tab/ddl_text.rs @@ -2,6 +2,8 @@ use tablepro_core::ddl::{BuildDdlError, DdlOperation, DraftProblem, RefusalReason}; +use crate::services::pending_structure::SaveBlocker; + /// Why a control is off, for its tooltip. pub(crate) fn refusal_text(reason: &RefusalReason) -> String { match reason { @@ -59,6 +61,14 @@ pub(crate) fn draft_problem_text(problem: &DraftProblem) -> String { } } +/// Why Save, from the tab or from a close prompt, refused. +pub(crate) fn save_blocker_text(blocker: &SaveBlocker) -> String { + match blocker { + SaveBlocker::Draft(problem) => draft_problem_text(problem), + SaveBlocker::UnparsedDefault(_) => crate::i18n::gettext("Fix the highlighted fields first."), + } +} + /// Why the change could not be written as SQL. pub(crate) fn build_error_text(error: &BuildDdlError) -> String { match error { diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index b378a6c46b..05d7e033de 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -62,8 +62,7 @@ use tablepro_session::runtime::{Tasks, WatchForwarder}; use tablepro_session::{LinkGeneration, OperationError, OperationKind, Session}; use self::columns::{ColumnRowContext, build_column_expander_row}; -pub(crate) use self::ddl_text::build_error_text; -use self::ddl_text::draft_problem_text; +pub(crate) use self::ddl_text::{build_error_text, save_blocker_text}; use self::fks::build_fk_row; use self::indexes::build_index_row; pub(crate) use self::structure_command::{LoadedStructure, StructureCommand, StructureRequestKind}; @@ -512,10 +511,17 @@ impl StructureModel { Some(original) => diff_table(original, &draft), None => TableChanges::default(), }; + let unparsed_defaults = self + .invalid_defaults + .keys() + .filter_map(|row| draft.columns.get(*row)) + .map(|column| column.name.clone()) + .collect(); PendingStructure { original, draft, changes, + unparsed_defaults, } } @@ -1037,10 +1043,11 @@ impl StructureModel { ColumnField::Comment(comment) => column.comment = comment, } // A rebuild sets each widget to the value the model already has, - // which is no edit. + // which is no edit. A default the parser refused is no edit + // either, but the tracker has to learn of it, or a close prompt + // would save without it. if column == previous { - self.refresh_buttons(self.pending().change_count()); - self.regenerate_sql_preview(&self.pending()); + self.recompute_dirty_state(fx); return; } if let Some(slot) = self.draft.borrow_mut().columns.get_mut(index) { @@ -1070,14 +1077,8 @@ impl StructureModel { fn save(&mut self, fx: &mut StructureEffects) { let pending = self.pending(); - if let Err(problem) = pending.draft.validate() { - fx.output(StructureTabOutput::ShowToast(draft_problem_text(&problem))); - return; - } - if !self.invalid_defaults.is_empty() { - fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext( - "Fix the highlighted fields first.", - ))); + if let Err(blocker) = pending.check() { + fx.output(StructureTabOutput::ShowToast(save_blocker_text(&blocker))); return; } if !pending.has_changes() { diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index eb6eaf7a2d..6625b342c1 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -513,6 +513,14 @@ fn an_edit_previews_the_plan_and_saves_through_the_tracker() { !tab.imp().save_button.is_sensitive(), "a default that does not parse can be saved" ); + // What a close prompt's Save reads. + let typed = tracker.begin_save().expect("the change is still pending"); + assert_eq!( + typed.check(), + Err(crate::services::pending_structure::SaveBlocker::UnparsedDefault( + "id".to_owned() + )) + ); drop(tab); drain_main_context(); runtime.shutdown(); diff --git a/linux/crates/core/src/ddl/ddl_operation.rs b/linux/crates/core/src/ddl/ddl_operation.rs index 3397b5327e..7fa9561b58 100644 --- a/linux/crates/core/src/ddl/ddl_operation.rs +++ b/linux/crates/core/src/ddl/ddl_operation.rs @@ -4,6 +4,7 @@ use std::fmt; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DdlOperation { CreateTable, + AlterTable, DropTable, DropTableCascade, RenameTable, @@ -31,6 +32,7 @@ impl fmt::Display for DdlOperation { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::CreateTable => "creating a table", + Self::AlterTable => "changing a table", Self::DropTable => "dropping a table", Self::DropTableCascade => "dropping a table with its dependents", Self::RenameTable => "renaming a table", From 587ff5d1efe624196fa39c228916170ed247d3cf Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:03:35 +0700 Subject: [PATCH 124/178] test(linux): rename a table in the DDL conformance suite --- .../test-support/src/conformance/cases/ddl.rs | 29 +++++++++++++++++++ linux/docs/testing.md | 4 +-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/linux/crates/test-support/src/conformance/cases/ddl.rs b/linux/crates/test-support/src/conformance/cases/ddl.rs index c472c8f6d4..e0731e385d 100644 --- a/linux/crates/test-support/src/conformance/cases/ddl.rs +++ b/linux/crates/test-support/src/conformance/cases/ddl.rs @@ -145,6 +145,35 @@ pub async fn ddl_plans_apply_and_read_back(engine: E) - }) .await?; + let renamed = TableRef::new(parent.schema.clone(), unique("ddl_renamed")); + let new_name = renamed.name.clone(); + let moved = step + .apply( + &mut table, + "rename table", + table_permission(capabilities.rename_table), + |draft| draft.table.name = new_name, + ) + .await?; + // A name the server resolved against the session's default schema + // would have moved the table out of its own. + let parent = if moved { + let scope = renamed + .schema + .clone() + .map_or(ObjectScope::DefaultSchema, ObjectScope::Schema); + let objects = connection.list_objects(&scope, within(CALL_DEADLINE)).await?; + let listed = objects + .iter() + .any(|object| object.kind == ObjectKind::Table && object.name == renamed.name); + expect(case, listed, || { + format!("{} is not listed in {scope:?} after the rename", renamed.name) + })?; + renamed + } else { + parent + }; + let drop = DropTableOptions { if_exists: false, cascade: capabilities.drop_table_cascade, diff --git a/linux/docs/testing.md b/linux/docs/testing.md index f8aa25b868..e38c41ccbe 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -141,11 +141,11 @@ tablepro_test_support::ddl_conformance_suite! { Every case plans through `connection.dialect().ddl()` and runs the plan with `write_batch`: -- `ddl_plans_apply_and_read_back` creates a table with a quoted literal default and an expression default, then adds, renames and drops a column, changes its nullability and default, adds an index and a foreign key, and drops the tables. After each step `fetch_table_definition` has to match the draft. A step the dialect refuses has to be refused by its plan as well, with a reason, so no step is skipped without a check. Once the foreign key exists, the referenced primary key has to read as held by it. +- `ddl_plans_apply_and_read_back` creates a table with a quoted literal default and an expression default, then adds, renames and drops a column, changes its nullability and default, adds an index and a foreign key, renames the table, which has to stay listed in its own schema, and drops the tables. After each step `fetch_table_definition` has to match the draft. A step the dialect refuses has to be refused by its plan as well, with a reason, so no step is skipped without a check. Once the foreign key exists, the referenced primary key has to read as held by it. - `failed_ddl_batch_outcome_matches_ddl_atomicity` sends two plans whose second statement the server rejects, and checks the outcome against `ddl_writes`: `RolledBack` and nothing applied, or `PartiallyApplied` and the first plan applied. - `create_table_script_recreates_the_table` drops a table and runs its `create_table_script`, which has to bring back the same column definitions and index keys. -All six targets run it; SQLite passes `docker: false`, so its cases run in the unit suite. +The PostgreSQL, MySQL, SQL Server, ClickHouse and SQLite targets run it, and the MySQL target runs it on MariaDB as well. SQLite passes `docker: false`, so its cases run in the unit suite. ### TLS conformance From 7d7b06d2e4e42cae2316c20bb2cd5aea92ba6bc5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:08:05 +0700 Subject: [PATCH 125/178] test(linux): build tab fixtures with the merged editor and structure inits --- linux/crates/app/src/ui/app/workspace_persistence.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/linux/crates/app/src/ui/app/workspace_persistence.rs b/linux/crates/app/src/ui/app/workspace_persistence.rs index 2bff0ef528..daf39339db 100644 --- a/linux/crates/app/src/ui/app/workspace_persistence.rs +++ b/linux/crates/app/src/ui/app/workspace_persistence.rs @@ -217,7 +217,6 @@ mod tests { fn add_editor(&self) -> (adw::TabPage, DraftId) { let editor = SqlEditor::new(SqlEditorInit { - schema_buffer: crate::ui::editor::build_schema_buffer(), initial_query: None, settings: self.settings.get().clone(), history: None, @@ -293,7 +292,6 @@ mod tests { schema: Some("public".to_owned()), table: table.to_owned(), mode, - driver_id: "postgres".to_owned(), session: session.session.clone(), tasks: fixture.runtime.tasks(), }); From 43f280e315a25f9732d27f79fe96b5af7a66acfe Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:43:46 +0700 Subject: [PATCH 126/178] feat(linux-core): render one row as a qualified INSERT statement --- linux/crates/core/src/edit/edit_text.rs | 4 +- linux/crates/core/src/edit/mod.rs | 2 +- linux/crates/core/src/export/mod.rs | 2 +- linux/crates/core/src/export/sql.rs | 86 ++++++++++++++++++++++++- 4 files changed, 88 insertions(+), 6 deletions(-) diff --git a/linux/crates/core/src/edit/edit_text.rs b/linux/crates/core/src/edit/edit_text.rs index fd1c51f008..5ba5037f64 100644 --- a/linux/crates/core/src/edit/edit_text.rs +++ b/linux/crates/core/src/edit/edit_text.rs @@ -17,7 +17,7 @@ pub enum EditText { /// committing it would write the shortened form back. A value with no /// text form the parser could read back is read-only. pub fn edit_text(value: &Value, column: &ColumnType) -> EditText { - if let Some(reason) = read_only_kind(&column.kind) { + if let Some(reason) = read_only_reason(&column.kind) { return EditText::ReadOnly(reason); } match value { @@ -35,7 +35,7 @@ pub fn edit_text(value: &Value, column: &ColumnType) -> EditText { /// The kinds whose values have no text the parser takes back. Matches /// what `parse_literal_text` refuses, so a cell that opens can commit. -fn read_only_kind(kind: &ColumnKind) -> Option { +pub fn read_only_reason(kind: &ColumnKind) -> Option { match kind { ColumnKind::Bytes { .. } => Some(ReadOnlyReason::Bytes), ColumnKind::Spatial => Some(ReadOnlyReason::Spatial), diff --git a/linux/crates/core/src/edit/mod.rs b/linux/crates/core/src/edit/mod.rs index c783715bd4..25201079c5 100644 --- a/linux/crates/core/src/edit/mod.rs +++ b/linux/crates/core/src/edit/mod.rs @@ -13,7 +13,7 @@ mod parse_literal_text; pub use cell_input::CellInput; pub use edit_context::EditContext; pub use edit_parse_error::{EditParseError, ReadOnlyReason}; -pub use edit_text::{EditText, edit_text}; +pub use edit_text::{EditText, edit_text, read_only_reason}; pub use empty_input_meaning::{EmptyInputMeaning, empty_input_meaning}; pub use is_unchanged::is_unchanged; pub use parse_edit_text::parse_edit_text; diff --git a/linux/crates/core/src/export/mod.rs b/linux/crates/core/src/export/mod.rs index 6e451b3eae..e910a2e5db 100644 --- a/linux/crates/core/src/export/mod.rs +++ b/linux/crates/core/src/export/mod.rs @@ -23,7 +23,7 @@ pub use in_clause::{InClause, render_in_clause}; pub use json::{render_json, row_to_json}; pub use json_field_names::json_field_names; pub use markdown::render_markdown; -pub use sql::{literal, render_sql_insert}; +pub use sql::{literal, render_insert, render_sql_insert}; pub use tsv::{render_tsv, tsv_writer_builder}; pub use value_text::{value_text, value_to_text}; pub use xlsx::render_xlsx; diff --git a/linux/crates/core/src/export/sql.rs b/linux/crates/core/src/export/sql.rs index 528c426646..83659052eb 100644 --- a/linux/crates/core/src/export/sql.rs +++ b/linux/crates/core/src/export/sql.rs @@ -1,5 +1,6 @@ -use crate::column::ResultColumn; -use crate::dialect::SqlDialect; +use crate::column::{ColumnInfo, ResultColumn}; +use crate::dialect::{LiteralError, SqlDialect}; +use crate::meta::TableRef; use crate::value::Value; /// Rows as `INSERT` statements, in the engine's own spelling. @@ -49,6 +50,44 @@ pub fn render_sql_insert( out } +/// One row as an `INSERT` the user can paste and run. +/// +/// Unlike an export, a value with no literal is an error rather than a +/// marked `NULL`: a single statement copied to run again is worth +/// nothing with a cell quietly swapped out. Generated columns are left +/// out, since the server refuses a value for them. +pub fn render_insert( + dialect: &dyn SqlDialect, + table: &TableRef, + columns: &[ColumnInfo], + row: &[Value], +) -> Result { + let table_sql = dialect.qualify(table); + let mut names = Vec::with_capacity(columns.len()); + let mut values = Vec::with_capacity(columns.len()); + for (index, column) in columns.iter().enumerate() { + if !column.is_writable() { + continue; + } + names.push(dialect.quote_identifier(&column.name)); + values.push(match row.get(index) { + Some(value) => dialect.literal(value, &column.column_type)?, + None => "NULL".to_owned(), + }); + } + if names.is_empty() { + return Ok(dialect + .insert_default_row(&table_sql) + .map(|sql| format!("{sql};")) + .unwrap_or_default()); + } + Ok(format!( + "INSERT INTO {table_sql} ({}) VALUES ({});", + names.join(", "), + values.join(", ") + )) +} + /// One cell as a literal, or a marked `NULL` where the engine has no /// spelling for it. pub fn literal(dialect: &dyn SqlDialect, value: &Value, column: &ResultColumn) -> String { @@ -134,4 +173,47 @@ mod tests { fn a_result_with_no_columns_writes_nothing() { assert_eq!(render("t", &[], &[]), ""); } + + fn column(name: &str, generated: bool) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: crate::column::ColumnType::untyped_text("text"), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: generated, + default: crate::column::ColumnDefault::None, + comment: None, + } + } + + #[test] + fn render_insert_qualifies_schema_and_skips_generated() { + let columns = [column("id", false), column("total", true), column("name", false)]; + let row = [Value::Int(1), Value::Int(9), Value::Text("O'Brien".into())]; + + let sql = render_insert( + &TestDialect::default(), + &TableRef::new(Some("sales".to_owned()), "orders"), + &columns, + &row, + ); + + assert_eq!( + sql.as_deref(), + Ok("INSERT INTO \"sales\".\"orders\" (\"id\", \"name\") VALUES (1, 'O''Brien');") + ); + } + + #[test] + fn render_insert_refuses_a_value_with_no_literal() { + let sql = render_insert( + &TestDialect::default(), + &TableRef::unqualified("t"), + &[column("blob", false)], + &[Value::Bytes(vec![0xde, 0xad])], + ); + + assert!(matches!(sql, Err(LiteralError::Unrepresentable { .. })), "{sql:?}"); + } } From 379f137ab56d2ca52722b6c64af99ad0d8da5118 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:43:53 +0700 Subject: [PATCH 127/178] fix(linux-grid): resolve cell events to row objects through the cell --- linux/ARCHITECTURE.md | 2 +- linux/CHANGELOG.md | 5 + .../crates/app/src/services/change_tracker.rs | 125 ++- .../app/src/services/tab_tracker_handle.rs | 15 +- .../crates/app/src/services/tracker_delta.rs | 8 +- linux/crates/app/src/ui/app/mod.rs | 5 - linux/crates/app/src/ui/app/row_ops.rs | 27 - linux/crates/app/src/ui/app/workspace_tabs.rs | 4 - linux/crates/app/src/ui/browse_tab/imp.rs | 7 - linux/crates/app/src/ui/browse_tab/mod.rs | 506 ++++----- linux/crates/app/src/ui/browse_tab/tests.rs | 154 ++- linux/crates/app/src/ui/editor/imp.rs | 4 +- linux/crates/app/src/ui/editor/mod.rs | 35 +- .../app/src/ui/editor/result_widgets.rs | 23 +- .../app/src/ui/grid/cell_editability.rs | 148 +++ linux/crates/app/src/ui/grid/cell_lock.rs | 16 + linux/crates/app/src/ui/grid/cell_popovers.rs | 182 ++-- .../crates/app/src/ui/grid/column_factory.rs | 956 ++++++++++-------- linux/crates/app/src/ui/grid/context_menu.rs | 93 +- linux/crates/app/src/ui/grid/edit_snapshot.rs | 10 + linux/crates/app/src/ui/grid/grid_cell.rs | 210 ++++ linux/crates/app/src/ui/grid/grid_menus.rs | 167 ++- linux/crates/app/src/ui/grid/grid_msg.rs | 24 +- linux/crates/app/src/ui/grid/grid_row_id.rs | 81 ++ linux/crates/app/src/ui/grid/inline_edit.rs | 182 ++-- linux/crates/app/src/ui/grid/mod.rs | 17 +- .../crates/app/src/ui/grid/row_json_dialog.rs | 4 +- linux/crates/app/src/ui/grid/row_object.rs | 104 +- linux/crates/app/src/ui/grid/selection.rs | 40 +- .../app/src/ui/grid/tab_grid_context.rs | 109 +- linux/crates/app/src/ui/grid/widget_slot.rs | 46 - linux/data/resources/style.css | 8 +- linux/po/POTFILES.in | 6 +- 33 files changed, 1945 insertions(+), 1378 deletions(-) create mode 100644 linux/crates/app/src/ui/grid/cell_editability.rs create mode 100644 linux/crates/app/src/ui/grid/cell_lock.rs create mode 100644 linux/crates/app/src/ui/grid/edit_snapshot.rs create mode 100644 linux/crates/app/src/ui/grid/grid_cell.rs create mode 100644 linux/crates/app/src/ui/grid/grid_row_id.rs delete mode 100644 linux/crates/app/src/ui/grid/widget_slot.rs diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 940a4ff0f3..190fca8b91 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -225,7 +225,7 @@ Each window owns two registries holding the in-flight edit state for its tabs, k | `ChangeTrackerRegistry` | `TabTrackerHandle` | Row-level INSERT / UPDATE / DELETE for Table tabs | `TabTrackerHandle::change_set` → `TrackedChanges` | | `StructureTrackerRegistry` | `StructureTrackerHandle` | The table's definition, the user's `TableDraft` and the `TableChanges` between them, for Structure tabs | `PendingStructure::plan` through the connection's `DdlDialect` → `DdlPlan` | -A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to rebind, the draft rows the list store gains, loses or updates, and the new pending count. The tab applies it to its `gio::ListStore` after the call returns. Every pending change carries a `Touch` from the tracker's counter. An `UndoOp` holds the pending state on both sides of a step, touch included, so undo after A, B, C shows B, not the fetched value. `change_set` emits one change per row in touch order, and `plan_save` keeps that order: a row deleted and replaced by a draft with the same unique value saves, because its DELETE runs first. `App::dirty_tabs` reads both registries for the close prompts, the teardown guard and the `•` title prefix. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. +A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to rebind, the draft rows the list store gains or loses, and the new pending count. The tab applies it to its `gio::ListStore` after the call returns. A grid row is a `RowObject` whose identity is fixed when it is made: a saved row's key and cells as fetched, or a draft's id, whose cells live only in the tracker. A grid cell resolves its row through its column view cell when an event fires, so an edit, a menu action or a toggle reaches the row it was made on even after rows above it came or went. Every pending change carries a `Touch` from the tracker's counter. An `UndoOp` holds the pending state on both sides of a step, touch included, so undo after A, B, C shows B, not the fetched value. `change_set` emits one change per row in touch order, and `plan_save` keeps that order: a row deleted and replaced by a draft with the same unique value saves, because its DELETE runs first. `App::dirty_tabs` reads both registries for the close prompts, the teardown guard and the `•` title prefix. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. The Structure tab is **definition + draft**, not a per-change log. The `TableDefinition` one read returned is what the tab plans against; `diff_table` turns the draft into `TableChanges`, and the engine's `DdlDialect` plans them into statements in its own order. Discard copies the definition back over the draft, and is the only restore point. The tracker holds the latest `PendingStructure`, so the close prompt and the save dispatcher read the same change without the tab, and the save plans it again with the dialect of the connection that runs it ([ADR 0010](docs/decisions/0010-ddl-plans-and-table-definitions.md)). diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index bfc60fefcc..281df0c2ad 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Structure tab offers only the changes the database can make, and says why a control is off. +- New rows in the table grid take a value for the primary key and auto-increment columns. - Show CREATE TABLE gives the server's own statement on MySQL, ClickHouse and SQLite. ### Fixed @@ -38,6 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ClickHouse DateTime values showed as UTC text when the server profile or session set ISO output. - ClickHouse save with an unknown outcome could read as applied because of another table's mutation. - A new row removed with Delete stayed in the grid while the save left it out. +- An edit, Set Value or row action in the table grid could land on another row once a row above it came or went. +- Copy as INSERT could copy the wrong row, and left out the schema and unsaved edits but kept generated columns. +- Duplicate left out the row's unsaved edits. +- Cells of a table without a primary key opened for editing, then dropped what was typed. - A save that failed on a new row did not flash that row. - Undo after editing a cell twice restored the fetched value instead of the first edit. - A save that deleted a row and added a new one with the same unique value failed. diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index 7f963d9a65..646731b0f6 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -264,12 +264,11 @@ impl TabChangeTracker { fn put_draft(&mut self, draft: DraftRow) -> TrackerDelta { let draft_id = draft.draft_id; - let visual = VisualChange::InsertDraft { - draft_id, - values: draft.values.clone(), - }; self.drafts.push(draft); - self.delta(vec![RowKey::Draft(draft_id)], vec![visual]) + self.delta( + vec![RowKey::Draft(draft_id)], + vec![VisualChange::InsertDraft { draft_id }], + ) } fn remove_draft(&mut self, draft_id: u64) -> TrackerDelta { @@ -287,12 +286,9 @@ impl TabChangeTracker { let Some(cell) = draft.values.get_mut(col) else { return self.delta(Vec::new(), Vec::new()); }; - *cell = value.clone(); + *cell = value; draft.touched = touched; - self.delta( - vec![RowKey::Draft(draft_id)], - vec![VisualChange::SetDraftCell { draft_id, col, value }], - ) + self.delta(vec![RowKey::Draft(draft_id)], Vec::new()) } pub fn undo(&mut self) -> Option { @@ -428,13 +424,30 @@ impl TabChangeTracker { true } + /// A draft's cells live only here, so for a draft `original` is + /// what an out-of-range column reads as. fn current_cell_value<'a>(&'a self, row_key: &RowKey, col: usize, original: &'a Value) -> &'a Value { + if let RowKey::Draft(draft_id) = row_key { + return self + .drafts + .iter() + .find(|draft| draft.draft_id == *draft_id) + .and_then(|draft| draft.values.get(col)) + .unwrap_or(original); + } match self.updates.get(&(row_key.clone(), col)) { Some(edit) => &edit.pending, None => original, } } + pub fn draft_values(&self, draft_id: u64) -> Option<&[Value]> { + self.drafts + .iter() + .find(|draft| draft.draft_id == draft_id) + .map(|draft| draft.values.as_slice()) + } + /// What the user changed, as the change set a save is planned from. /// /// One change per row, ordered by when the row was last touched, so @@ -557,13 +570,14 @@ fn key_values(key: &[KeyValue]) -> Vec { } /// A NULL the user left in a column with a default means "use the -/// default" (CURRENT_TIMESTAMP, gen_random_uuid()), not an explicit -/// NULL over it. +/// default" (CURRENT_TIMESTAMP, gen_random_uuid(), the next serial), +/// not an explicit NULL over it. A value typed into an auto-increment +/// column is the user's to keep. fn draft_cell(column: &ColumnInfo, value: &Value) -> CellInput { - if column.is_auto_increment || column.is_generated { + if column.is_generated { return CellInput::Default; } - if matches!(value, Value::Null) && !column.default.is_none() { + if matches!(value, Value::Null) && (column.is_auto_increment || !column.default.is_none()) { return CellInput::Default; } CellInput::Value(value.clone()) @@ -864,24 +878,11 @@ mod tests { let undone = t.undo(); - assert_eq!( - undone.map(|delta| delta.visual), - Some(vec![VisualChange::SetDraftCell { - draft_id, - col: 1, - value: text("b") - }]) - ); + assert_eq!(undone.map(|delta| delta.rows), Some(vec![RowKey::Draft(draft_id)])); assert_eq!(t.drafts()[0].values[1], text("b")); - let redone = t.redo().map(|delta| delta.visual); - assert_eq!( - redone, - Some(vec![VisualChange::SetDraftCell { - draft_id, - col: 1, - value: text("c") - }]) - ); + let redone = t.redo().map(|delta| delta.rows); + assert_eq!(redone, Some(vec![RowKey::Draft(draft_id)])); + assert_eq!(t.drafts()[0].values[1], text("c")); } #[test] @@ -1104,6 +1105,25 @@ mod tests { ); } + #[test] + fn a_draft_keeps_a_value_typed_into_an_auto_increment_column() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("id"), data_col("name")]; + t.track_insert(vec![Value::Int(42), Value::Text("draft".into())]); + + let tracked = t.change_set(&columns).unwrap(); + + assert_eq!( + tracked.changes.changes, + vec![RowChange::Insert { + cells: vec![ + CellInput::Value(Value::Int(42)), + CellInput::Value(Value::Text("draft".into())) + ] + }] + ); + } + #[test] fn a_table_without_a_primary_key_cannot_save_an_update() { let mut t = TabChangeTracker::default(); @@ -1214,22 +1234,31 @@ mod tests { let undone = t.undo(); assert_eq!( - edited.visual, - vec![VisualChange::SetDraftCell { - draft_id, - col: 1, - value: Value::Text("b".into()) - }] + (edited.rows, edited.visual), + (vec![RowKey::Draft(draft_id)], Vec::new()) ); + assert_eq!(undone.map(|delta| delta.rows), Some(vec![RowKey::Draft(draft_id)])); + assert_eq!(t.drafts()[0].values[1], Value::Text("a".into())); + } + + #[test] + fn a_draft_cell_reads_from_the_tracker() { + let mut t = TabChangeTracker::default(); + let draft_id = insert(&mut t, vec![Value::Null, Value::Text("a".into())]); + let _ = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 1, + value: Value::Text("b".into()), + }); + + let overlay = t.cell_overlay(&RowKey::Draft(draft_id), 1, &Value::Null); + + assert_eq!(overlay.value, Value::Text("b".into())); + assert_eq!(overlay.state, CellState::InsertDraft); assert_eq!( - undone.map(|delta| delta.visual), - Some(vec![VisualChange::SetDraftCell { - draft_id, - col: 1, - value: Value::Text("a".into()) - }]) + t.cell_overlay(&RowKey::Draft(draft_id), 9, &Value::Null).value, + Value::Null ); - assert_eq!(t.drafts()[0].values[1], Value::Text("a".into())); } #[test] @@ -1256,13 +1285,7 @@ mod tests { let redone = t.redo().map(|delta| delta.visual); assert_eq!(undone, Some(vec![VisualChange::RemoveDraft { draft_id }])); - assert_eq!( - redone, - Some(vec![VisualChange::InsertDraft { - draft_id, - values: vec![Value::Int(7)] - }]) - ); + assert_eq!(redone, Some(vec![VisualChange::InsertDraft { draft_id }])); } #[test] diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs index b3bf902f5c..7696e13772 100644 --- a/linux/crates/app/src/services/tab_tracker_handle.rs +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -47,6 +47,10 @@ impl TabTrackerHandle { self.0.borrow().drafts().to_vec() } + pub fn draft_values(&self, draft_id: u64) -> Option> { + self.0.borrow().draft_values(draft_id).map(<[Value]>::to_vec) + } + pub fn change_set(&self, columns: &[ColumnInfo]) -> Result { self.0.borrow().change_set(columns) } @@ -91,10 +95,13 @@ mod tests { assert_eq!(edited.rows, vec![row(1)]); assert_eq!(edited.pending_count, 1); assert_eq!(inserted.pending_count, 2); - assert!(matches!( - inserted.visual.as_slice(), - [VisualChange::InsertDraft { values, .. }] if values[1] == Value::Text("c".into()) - )); + let [VisualChange::InsertDraft { draft_id }] = inserted.visual.as_slice() else { + panic!("an insert adds one draft row: {:?}", inserted.visual); + }; + assert_eq!( + tracker.draft_values(*draft_id), + Some(vec![Value::Null, Value::Text("c".into())]) + ); assert_eq!(tracker.pending_count(), 2); } diff --git a/linux/crates/app/src/services/tracker_delta.rs b/linux/crates/app/src/services/tracker_delta.rs index 0e13a043ad..6529eedeb0 100644 --- a/linux/crates/app/src/services/tracker_delta.rs +++ b/linux/crates/app/src/services/tracker_delta.rs @@ -1,5 +1,3 @@ -use tablepro_core::Value; - use super::change_tracker::RowKey; /// What a tracker change means for the grid, handed back once the @@ -8,15 +6,13 @@ use super::change_tracker::RowKey; pub struct TrackerDelta { /// Rows whose pending state changed and need a rebind. pub rows: Vec, - /// Draft rows the list store has to gain, lose or update, since a - /// draft's cells live on its row object. + /// Draft rows the list store has to gain or lose. pub visual: Vec, pub pending_count: usize, } #[derive(Debug, Clone, PartialEq)] pub enum VisualChange { - SetDraftCell { draft_id: u64, col: usize, value: Value }, RemoveDraft { draft_id: u64 }, - InsertDraft { draft_id: u64, values: Vec }, + InsertDraft { draft_id: u64 }, } diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index a55f073171..a628020c32 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -404,10 +404,6 @@ pub enum AppMsg { target: Option, }, CopyToClipboard(String), - CopyRowAsInsert { - tab_id: Uuid, - row_position: u32, - }, // ── Workspace tab routing ──────────────────────────────────────── /// User clicked the close-X on any workspace tab. @@ -1427,7 +1423,6 @@ impl SimpleComponent for App { ) } AppMsg::CopyToClipboard(text) => self.on_copy_to_clipboard(text), - AppMsg::CopyRowAsInsert { tab_id, row_position } => self.on_copy_row_as_insert(tab_id, row_position), AppMsg::DeleteConnection(id) => self.on_delete_connection(id, sender), AppMsg::DuplicateConnection(saved) => self.on_duplicate_connection(saved, sender), AppMsg::OpenSaved(saved) => self.on_open_saved(saved, sender), diff --git a/linux/crates/app/src/ui/app/row_ops.rs b/linux/crates/app/src/ui/app/row_ops.rs index 5c68af56f5..658c5aa2a0 100644 --- a/linux/crates/app/src/ui/app/row_ops.rs +++ b/linux/crates/app/src/ui/app/row_ops.rs @@ -124,33 +124,6 @@ impl App { self.row_op_spinner.set_visible(in_flight); } - pub(super) fn on_copy_row_as_insert(&self, tab_id: Uuid, row_position: u32) { - let Some(browse) = self - .workspace_tabs - .borrow() - .get(&tab_id) - .and_then(|t| t.browse_tab()) - .cloned() - else { - return; - }; - let (columns, snapshot, table) = (browse.columns(), browse.snapshot(), browse.table()); - let Some(snapshot) = snapshot else { return }; - let Some(row) = snapshot.rows.get(row_position as usize) else { - return; - }; - let Some(dialect) = self.context.active.get().and_then(|session| session.current_dialect()) else { - self.show_toast(&crate::i18n::gettext("Connect to a database first.")); - return; - }; - let result_columns: Vec<_> = columns.iter().map(|column| column.result_column()).collect(); - let sql = - tablepro_core::export::render_sql_insert(&*dialect, &table, &result_columns, std::slice::from_ref(row)); - let sql = sql.trim_end().to_owned(); - self.window.clipboard().set_text(&sql); - self.show_toast(&crate::i18n::gettext("INSERT statement copied")); - } - pub(super) fn on_copy_to_clipboard(&self, text: String) { self.window.clipboard().set_text(&text); self.show_toast(&crate::i18n::gettext("Copied to clipboard")); diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index 3a0c958d96..75b8f721f5 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -1380,10 +1380,6 @@ fn connect_structure(structure: &StructureTab, tab_id: Uuid, input: &relm4::Send fn connect_browse(browse: &BrowseTab, tab_id: Uuid, input: &relm4::Sender) { let state = input.clone(); browse.connect_state_changed(move |_| state.emit(AppMsg::WorkspaceTabsChanged)); - let copy_row = input.clone(); - browse.connect_copy_row_as_insert(move |_, row_position| { - copy_row.emit(AppMsg::CopyRowAsInsert { tab_id, row_position }); - }); let clipboard = input.clone(); browse.connect_copy_to_clipboard(move |_, text| clipboard.emit(AppMsg::CopyToClipboard(text))); let export = input.clone(); diff --git a/linux/crates/app/src/ui/browse_tab/imp.rs b/linux/crates/app/src/ui/browse_tab/imp.rs index 1e8e8ac0eb..12feda22ae 100644 --- a/linux/crates/app/src/ui/browse_tab/imp.rs +++ b/linux/crates/app/src/ui/browse_tab/imp.rs @@ -67,7 +67,6 @@ pub struct BrowseTab { pub(super) dispatching: Cell, /// Delivers each reconnect, for as long as the tab is open. pub(super) link_watch: RefCell>, - pub(super) grid_forward: RefCell>>, } #[glib::object_subclass] @@ -166,9 +165,6 @@ impl ObjectImpl for BrowseTab { SIGNALS.get_or_init(|| { vec![ Signal::builder("state-changed").build(), - Signal::builder("copy-row-as-insert") - .param_types([u32::static_type()]) - .build(), Signal::builder("copy-to-clipboard") .param_types([String::static_type()]) .build(), @@ -193,9 +189,6 @@ impl ObjectImpl for BrowseTab { /// finish it for nobody. fn dispose(&self) { self.link_watch.take(); - if let Some(forwarding) = self.grid_forward.take() { - forwarding.abort(); - } if let Ok(mut model) = self.model.try_borrow_mut() && let Some(mut model) = model.take() { diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index ea583ca869..f6761d936f 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -34,9 +34,12 @@ use tablepro_session::{LinkGeneration, Session}; pub(crate) use self::browse_command::{BrowseCommand, ColumnsReply}; pub(crate) use self::browse_request_kind::BrowseRequestKind; pub(crate) use self::page_reply::PageReply; -use super::grid::{CellEditor, CellPreset, ColumnWidthBinding, GridMsg, RowObject, TabGridContext, build_column_view}; +use super::grid::{ + CellPreset, ColumnWidthBinding, GridCell, GridMsg, GridRowId, GridSink, RowObject, TabGridContext, + build_column_view, cell_editability, +}; use crate::runtime::{FailedRequests, RequestSequencer}; -use crate::services::change_tracker::{RowKey, StatementSource, TrackedChanges}; +use crate::services::change_tracker::{RowKey, StatementSource, TrackedChanges, pk_identity}; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_delta::{TrackerDelta, VisualChange}; use crate::services::tracker_edit::TrackerEdit; @@ -144,7 +147,14 @@ impl BrowseTab { tab.collapse_top_bars_with(&filter_strip.widget); tab.connect_widgets(); - let (grid_sender, grid_receiver) = relm4::channel::(); + let grid_sink: GridSink = { + let tab = tab.downgrade(); + Rc::new(move |msg| { + if let Some(tab) = tab.upgrade() { + tab.send(BrowseTabInput::from(msg)); + } + }) + }; let link_generation = init.session.link_generation(); let dialect = init.session.current_dialect(); let link_changes = init.session.link_changes(); @@ -199,11 +209,10 @@ impl BrowseTab { save_button: imp.save_button.get(), discard_button: imp.discard_button.get(), pending_label: imp.pending_label.get(), - grid_sender, + grid_sink, suppress_combo_emit: Cell::new(true), }; imp.model.replace(Some(model)); - tab.forward_grid(grid_receiver); imp.link_watch.replace(Some(WatchForwarder::new( link_changes, { @@ -304,14 +313,6 @@ impl BrowseTab { self.connect_closure("state-changed", false, glib::closure_local!(move |tab: &Self| f(tab))) } - pub fn connect_copy_row_as_insert(&self, f: F) -> glib::SignalHandlerId { - self.connect_closure( - "copy-row-as-insert", - false, - glib::closure_local!(move |tab: &Self, row: u32| f(tab, row)), - ) - } - pub fn connect_copy_to_clipboard(&self, f: F) -> glib::SignalHandlerId { self.connect_closure( "copy-to-clipboard", @@ -368,9 +369,6 @@ impl BrowseTab { fn emit_output(&self, output: BrowseTabOutput) { match output { BrowseTabOutput::StateChanged => self.emit_by_name::<()>("state-changed", &[]), - BrowseTabOutput::CopyRowAsInsert { row_position } => { - self.emit_by_name::<()>("copy-row-as-insert", &[&row_position]); - } BrowseTabOutput::CopyToClipboard(text) => self.emit_by_name::<()>("copy-to-clipboard", &[&text]), BrowseTabOutput::ExportResults(request) => { self.emit_by_name::<()>("export-results", &[&glib::BoxedAnyObject::new(request)]); @@ -444,34 +442,25 @@ impl BrowseTab { }); } - /// Each grid event becomes an input of this tab, until the tab goes. - fn forward_grid(&self, receiver: relm4::Receiver) { - let tab = self.downgrade(); - let forwarding = glib::spawn_future_local(async move { - while let Some(msg) = receiver.recv().await { - let Some(tab) = tab.upgrade() else { - break; - }; - tab.send(BrowseTabInput::from(msg)); - } - }); - self.imp().grid_forward.replace(Some(forwarding)); - } - - /// Set the focused cell to NULL, for Ctrl+Shift+N. + /// Set the focused cell to NULL, for Ctrl+Shift+N. A locked cell + /// lets the key go on. fn set_focused_cell_null(&self) -> glib::Propagation { - let Some((row_position, col_index)) = super::grid::focused_cell_coords(self) else { + // `RootExt::focus` would return the window itself. + let focus = self + .root() + .and_downcast::() + .and_then(|window| gtk::prelude::GtkWindowExt::focus(&window)); + let Some(cell) = focus.as_ref().and_then(GridCell::from_focus) else { return glib::Propagation::Proceed; }; - if let Some(sender) = self.read(|model| model.grid_sender.clone()) { - sender - .send(GridMsg::SetCellValue { - row_position, - col_index, - preset: CellPreset::Null, - }) - .ok(); - } + let (None, Some(row)) = (cell.lock(), cell.row()) else { + return glib::Propagation::Proceed; + }; + self.send(BrowseTabInput::GridSetCellValue { + row, + column: cell.column(), + preset: CellPreset::Null, + }); glib::Propagation::Stop } } @@ -480,31 +469,15 @@ impl From for BrowseTabInput { fn from(msg: GridMsg) -> Self { match msg { GridMsg::SortChanged(col_idx, ascending) => Self::SortChanged { col_idx, ascending }, - GridMsg::CellEdited { - row_position, - col_index, - new_value, - } => Self::GridCellEdited { - row_position, - col_index, - new_value, - }, + GridMsg::CellEdited { row, column, input } => Self::GridCellEdited { row, column, input }, GridMsg::CopyToClipboard(text) => Self::GridCopyToClipboard(text), GridMsg::ShowToast(text) => Self::GridShowToast(text), - GridMsg::CopyRowAsInsert { row_position } => Self::GridCopyRowAsInsert { row_position }, - GridMsg::SetCellValue { - row_position, - col_index, - preset, - } => Self::GridSetCellValue { - row_position, - col_index, - preset, - }, + GridMsg::CopyRowAsInsert { row } => Self::GridCopyRowAsInsert { row }, + GridMsg::SetCellValue { row, column, preset } => Self::GridSetCellValue { row, column, preset }, GridMsg::ExportResults(result) => Self::GridExportResults(result), - GridMsg::DeleteRowAt { row_position } => Self::GridDeleteRowAt { row_position }, + GridMsg::DeleteRowAt { row } => Self::GridDeleteRowAt { row }, GridMsg::InsertRow => Self::InsertRow, - GridMsg::DuplicateRow { row_position } => Self::DuplicateRow { row_position }, + GridMsg::DuplicateRow { row } => Self::DuplicateRow { row }, } } } @@ -612,7 +585,7 @@ pub(crate) struct BrowseModel { save_button: gtk::Button, discard_button: gtk::Button, pending_label: gtk::Label, - grid_sender: relm4::Sender, + grid_sink: GridSink, /// Set until the first page lands, so the page-size dropdown being /// set from a restored state does not count as the user choosing. suppress_combo_emit: Cell, @@ -652,30 +625,30 @@ pub(crate) enum BrowseTabInput { FocusInsertedDraft, DeleteSelectedRow, GridCellEdited { - row_position: u32, - col_index: usize, - new_value: String, + row: RowObject, + column: usize, + input: String, }, /// Cell context-menu "Set Value". The grid names the preset; the /// tab resolves it against the column's declared type, because /// only the tab holds the column metadata that says whether an /// empty string is a value the column can hold. GridSetCellValue { - row_position: u32, - col_index: usize, + row: RowObject, + column: usize, preset: CellPreset, }, GridShowToast(String), GridExportResults(QueryResult), ExportCurrentPage, GridDeleteRowAt { - row_position: u32, + row: RowObject, }, GridCopyRowAsInsert { - row_position: u32, + row: RowObject, }, DuplicateRow { - row_position: u32, + row: RowObject, }, GridCopyToClipboard(String), Undo, @@ -706,9 +679,6 @@ pub(crate) enum BrowseTabOutput { /// Something the window keeps for the tab changed: the page, the /// sort, the page size. StateChanged, - CopyRowAsInsert { - row_position: u32, - }, CopyToClipboard(String), ExportResults(ExportRequest), ShowSelectionAlert { @@ -743,16 +713,42 @@ impl BrowseModel { fn grid_context(&self) -> TabGridContext { TabGridContext { tracker: Some(self.tracker.clone()), + editable: !self.read_only, + identity: pk_identity(&self.current_columns), column_widths: self.connection_id.map(|connection_id| ColumnWidthBinding { store: self.column_widths.clone(), connection_id, schema: self.schema.clone(), table: self.table.clone(), }), - pk_col_indices: self.pk_indices(), } } + /// A page's rows, each named by its primary key, which is the key + /// the tracker files its edits under. + fn page_rows(&self, result: &QueryResult) -> Vec { + let pk_indices = self.pk_indices(); + result + .rows + .iter() + .map(|cells| RowObject::persisted(persisted_key(cells, &pk_indices), cells.clone().into())) + .collect() + } + + /// Checked again here because a keyboard shortcut or a late event + /// can reach a locked cell. + fn refuses_edit(&self, row: &RowObject, column: usize) -> bool { + let Some(info) = self.current_columns.get(column) else { + return true; + }; + let locked = cell_editability(info, row.id(), &pk_identity(&self.current_columns), self.read_only); + if let Err(lock) = locked { + tracing::debug!(?lock, column, "refused an edit to a locked cell"); + return true; + } + false + } + fn pk_indices(&self) -> Vec { self.current_columns .iter() @@ -791,15 +787,12 @@ impl BrowseModel { } } - /// Whether an edit at this position lands in a draft row, which is - /// the one place an empty cell can mean the server's default. - fn edit_context_at(&self, row_position: u32) -> EditContext { - match self - .row_object_at(row_position) - .is_some_and(|row| row.draft_id().is_some()) - { - true => EditContext::InsertDraft, - false => EditContext::UpdateRow, + /// Whether an edit lands in a draft row, which is the one place an + /// empty cell can mean the server's default. + fn edit_context_for(row: &RowObject) -> EditContext { + match row.id() { + GridRowId::Draft(_) => EditContext::InsertDraft, + GridRowId::Result | GridRowId::Unkeyed | GridRowId::Persisted(_) => EditContext::UpdateRow, } } @@ -835,8 +828,8 @@ impl BrowseModel { } } for key in &delta.rows { - if let Some(position) = self.find_row_position_by_key(key) { - self.refresh_row_at(position); + if let Some((_, row)) = self.row_for_key(key) { + self.refresh_row(&row); } } self.sync_pending_chrome(delta.pending_count, fx); @@ -857,28 +850,36 @@ impl BrowseModel { self.apply_tracker_delta(delta, fx); } - /// A typed or preset value for one cell. A draft keeps the value on - /// its own row; a persisted row keeps it in the tracker. - fn edit_cell(&self, row_position: u32, col: usize, value: Value, fx: &mut Effects) { - if let Some(draft_id) = self.row_object_at(row_position).and_then(|row| row.draft_id()) { - self.apply_edit(TrackerEdit::DraftCell { draft_id, col, value }, fx); - return; + /// A typed or preset value for one cell, against the row the edit + /// was made on, wherever it sits now. + fn edit_cell(&self, row: &RowObject, col: usize, value: Value, fx: &mut Effects) { + match row.id() { + GridRowId::Draft(draft_id) => { + self.apply_edit( + TrackerEdit::DraftCell { + draft_id: *draft_id, + col, + value, + }, + fx, + ); + } + GridRowId::Persisted(row_key) => { + let Some(original) = row.cell(col).cloned() else { + return; + }; + self.apply_edit( + TrackerEdit::Cell { + row_key: row_key.clone(), + col, + original, + value, + }, + fx, + ); + } + GridRowId::Result | GridRowId::Unkeyed => {} } - let Some((row_key, row)) = self.row_key_at(row_position) else { - return; - }; - let Some(original) = row.get(col).cloned() else { - return; - }; - self.apply_edit( - TrackerEdit::Cell { - row_key, - col, - original, - value, - }, - fx, - ); } /// F5: read the columns again, in case the table changed under the @@ -903,68 +904,38 @@ impl BrowseModel { selection.model()?.downcast::().ok() } - /// Make the view rebind the row at `pos`. + /// Make the view rebind `row`, which keeps its identity but whose + /// pending state changed. /// /// The ColumnView's list-item manager compares item pointers and - /// skips the rebind when the item at a position is the same object, - /// and an edit changes cells inside the existing `RowObject`. So a - /// fresh `RowObject` with the post-edit cells is spliced in: one - /// items-changed, no flicker, no scroll jump. - fn refresh_row_at(&self, pos: u32) { - let row_obj = self.row_object_at(pos); - let store = self.list_store(); - let store_pos = match (row_obj.as_ref(), store.as_ref()) { - (Some(r), Some(s)) => s.find(r), - _ => None, + /// skips the rebind when the item at a position is the same object. + /// So a fresh object for the row is spliced in: one items-changed, + /// no flicker, no scroll jump. An event can carry an object an + /// earlier rebind replaced, so a keyed row is found by its key. + fn refresh_row(&self, row: &RowObject) { + let Some(store) = self.list_store() else { + return; }; - if let (Some(store), Some(store_pos), Some(old)) = (store, store_pos, row_obj) { - let cells = old.cells_clone(); - let replacement = match old.draft_id() { - Some(id) => RowObject::new_draft(id, cells), - None => RowObject::new(cells), - }; - store.splice(store_pos, 1, &[replacement]); + let position = store.find(row).or_else(|| { + let key = row.id().tracker_key()?; + self.row_for_key(&key).map(|(position, _)| position) + }); + let Some(position) = position else { + return; + }; + if let Some(current) = store.item(position).and_downcast::() { + store.splice(position, 1, &[current.replacement()]); } } - fn row_object_at(&self, position: u32) -> Option { - let model = self.current_selection.as_ref()?.model()?; - model.item(position)?.downcast::().ok() - } - - /// Where a row is in the selection model: a draft by its id, a - /// persisted row by its key values. `None` when it is not on this - /// page. - fn find_row_position_by_key(&self, key: &RowKey) -> Option { - use crate::services::change_tracker::KeyValue; - let selection = self.current_selection.as_ref()?; - let model = selection.model()?; - let pk_indices = self.pk_indices(); - let n_items = model.n_items(); - for i in 0..n_items { - let Some(item) = model.item(i) else { continue }; - let Ok(row) = item.downcast::() else { - continue; - }; - let matched = match key { - RowKey::Draft(id) => row.draft_id() == Some(*id), - RowKey::Persisted(target_keys) => { - if row.draft_id().is_some() || pk_indices.is_empty() { - false - } else { - let row_keys: Vec = pk_indices - .iter() - .map(|&col_idx| tablepro_core::value::ValueIdentity::new(row.cell_value(col_idx))) - .collect(); - row_keys == *target_keys - } - } - }; - if matched { - return Some(i); - } - } - None + /// The row the tracker names `key`, and where it is. `None` when it + /// is not on this page. + fn row_for_key(&self, key: &RowKey) -> Option<(u32, RowObject)> { + let store = self.list_store()?; + (0..store.n_items()).find_map(|position| { + let row = store.item(position).and_downcast::()?; + row.id().is_tracked_as(key).then_some((position, row)) + }) } /// Scroll to the row behind a failed statement and flash it. A row @@ -975,7 +946,7 @@ impl BrowseModel { StatementSource::Insert { draft_id } => RowKey::Draft(*draft_id), StatementSource::Update { row_key } | StatementSource::Delete { row_key } => row_key.clone(), }; - let Some(position) = self.find_row_position_by_key(&key) else { + let Some((position, row)) = self.row_for_key(&key) else { return; }; if let Some(selection) = self.current_selection.as_ref() { @@ -990,7 +961,7 @@ impl BrowseModel { ); } let generation = self.tracker.flash_error_row(key); - self.refresh_row_at(position); + self.refresh_row(&row); let tracker = self.tracker.clone(); let selection_for_clear = self.current_selection.clone(); // Matches the length of the flash animation in the stylesheet. @@ -1026,24 +997,10 @@ impl BrowseModel { if bitset.size() == 0 { return; } - let pos = bitset.nth(0); - let Some(model) = selection.model() else { return }; - let Some(item) = model.item(pos) else { return }; - let Ok(row) = item.downcast::() else { + let Some(row) = selection.item(bitset.nth(0)).and_downcast::() else { return; }; - let key = if let Some(draft_id) = row.draft_id() { - Some(RowKey::Draft(draft_id)) - } else { - let pk_indices = self.pk_indices(); - if pk_indices.is_empty() { - None - } else { - let pk_values: Vec = pk_indices.iter().map(|&i| row.cell_value(i)).collect(); - RowKey::from_pk_values(&pk_values) - } - }; - *self.pending_focus_restore.borrow_mut() = key; + *self.pending_focus_restore.borrow_mut() = row.id().tracker_key(); } /// Scroll + focus + select the freshly-prepended draft row, then @@ -1080,11 +1037,8 @@ impl BrowseModel { let Some(focused) = gtk::prelude::GtkWindowExt::focus(&window) else { return; }; - if let Ok(label) = focused.dynamic_cast::() { - if label.text().as_str() == super::grid::editable_null_sentinel() { - label.set_text(""); - } - label.start_editing(); + if let Some(cell) = GridCell::from_focus(&focused) { + super::grid::enter_edit_mode(&cell); } }); } @@ -1096,7 +1050,7 @@ impl BrowseModel { let Some(key) = self.pending_focus_restore.borrow_mut().take() else { return; }; - let Some(position) = self.find_row_position_by_key(&key) else { + let Some((position, _)) = self.row_for_key(&key) else { return; }; if let Some(selection) = self.current_selection.as_ref() { @@ -1135,11 +1089,11 @@ impl BrowseModel { let tab_ctx = self.grid_context(); let (column_view, selection) = build_column_view( &result, + self.page_rows(&result), &self.current_columns, - self.grid_sender.clone(), - !self.read_only, + self.grid_sink.clone(), self.current_sort, - Some(self.grid_sender.clone()), + true, tab_ctx, ); self.current_selection = Some(selection); @@ -1180,8 +1134,8 @@ impl BrowseModel { /// they survive the swap. fn refresh_grid_data(&self, result: &QueryResult, store: >k::gio::ListStore) { store.remove_all(); - for row in &result.rows { - store.append(&RowObject::new(row.clone())); + for row in self.page_rows(result) { + store.append(&row); } self.reprepend_drafts(); } @@ -1224,19 +1178,8 @@ impl BrowseModel { return; }; for draft in self.tracker.drafts() { - store.insert(0, &RowObject::new_draft(draft.draft_id, draft.values)); - } - } - - /// The (RowKey, current_values) pair for a row at a selection-model - /// position, which counts prepended drafts. `None` for drafts, for - /// tables with no PK, and out of range. - fn row_key_at(&self, row_position: u32) -> Option<(RowKey, Vec)> { - let row_obj = self.row_object_at(row_position)?; - if row_obj.draft_id().is_some() { - return None; + store.insert(0, &RowObject::draft(draft.draft_id)); } - build_persisted_row_key(row_obj.cells_clone(), &self.pk_indices()) } fn has_primary_key(&self) -> bool { @@ -1530,17 +1473,13 @@ impl BrowseModel { self.fetch(BrowseRequestKind::Page, fx); fx.output(BrowseTabOutput::StateChanged); } - BrowseTabInput::DuplicateRow { row_position } => { + BrowseTabInput::DuplicateRow { row } => { if self.current_columns.is_empty() { return; } - // Through the live RowObject: the position counts the - // user's sort and any prepended drafts, which the fetched - // rows do not. - let Some(source) = self.row_object_at(row_position) else { - return; - }; - let source_cells = source.cells_clone(); + // With its pending edits, since the copy is of the row the + // user is looking at. + let source_cells = self.grid_context().effective_cells(&row); // The duplicate is a new row, so values the database owns // (key, identity, generated) are left to it. let values: Vec = self @@ -1569,47 +1508,46 @@ impl BrowseModel { fx.input(BrowseTabInput::FocusInsertedDraft); } BrowseTabInput::DeleteSelectedRow => self.delete_selected_rows(fx), - BrowseTabInput::GridCellEdited { - row_position, - col_index, - new_value, - } => { + BrowseTabInput::GridCellEdited { row, column, input } => { + if self.refuses_edit(&row, column) { + self.refresh_row(&row); + return; + } // A column that holds line breaks keeps them; everywhere // else a pasted newline would reach the SQL layer. let keeps_line_breaks = self .current_columns - .get(col_index) + .get(column) .is_some_and(|c| c.column_type.kind.accepts_line_breaks()); let normalized = match keeps_line_breaks { - true => new_value, - false => normalize_single_line_input(&new_value), + true => input, + false => normalize_single_line_input(&input), }; - let col = self.current_columns.get(col_index); - let new = match parse_input_for_column(&normalized, col, self.edit_context_at(row_position)) { + let col = self.current_columns.get(column); + let new = match parse_input_for_column(&normalized, col, Self::edit_context_for(&row)) { Ok(v) => v, Err(message) => { // The cell still shows what was typed; a rebind // puts the value back. fx.output(BrowseTabOutput::ShowToast(message)); - self.refresh_row_at(row_position); + self.refresh_row(&row); return; } }; - self.edit_cell(row_position, col_index, new, fx); + self.edit_cell(&row, column, new, fx); } - BrowseTabInput::GridSetCellValue { - row_position, - col_index, - preset, - } => { - let value = match self.resolve_cell_preset(preset, col_index, self.edit_context_at(row_position)) { + BrowseTabInput::GridSetCellValue { row, column, preset } => { + if self.refuses_edit(&row, column) { + return; + } + let value = match self.resolve_cell_preset(preset, column, Self::edit_context_for(&row)) { Ok(value) => value, Err(message) => { fx.output(BrowseTabOutput::ShowToast(message)); return; } }; - self.edit_cell(row_position, col_index, value, fx); + self.edit_cell(&row, column, value, fx); } BrowseTabInput::GridShowToast(message) => { fx.output(BrowseTabOutput::ShowToast(message)); @@ -1635,21 +1573,19 @@ impl BrowseModel { target: Some(self.sql_target()), })); } - BrowseTabInput::GridDeleteRowAt { row_position } => { - let Some((row_key, original_values)) = self.row_key_at(row_position) else { + BrowseTabInput::GridDeleteRowAt { row } => { + let (Some(row_key), Some(cells)) = (row.key(), row.cells()) else { return; }; self.apply_edit( TrackerEdit::Delete { - row_key, - original_values, + row_key: row_key.clone(), + original_values: cells.to_vec(), }, fx, ); } - BrowseTabInput::GridCopyRowAsInsert { row_position } => { - fx.output(BrowseTabOutput::CopyRowAsInsert { row_position }); - } + BrowseTabInput::GridCopyRowAsInsert { row } => self.copy_row_as_insert(&row, fx), BrowseTabInput::GridCopyToClipboard(text) => { fx.output(BrowseTabOutput::CopyToClipboard(text)); } @@ -1775,31 +1711,23 @@ impl BrowseModel { if positions.is_empty() { return; } - let pk_indices = self.pk_indices(); - // Resolved through the selection model: its positions count the - // prepended drafts, which the fetched rows do not. - let Some(model) = selection.model() else { - return; - }; let mut snapshot: Vec<(RowKey, Vec)> = Vec::new(); let mut draft_ids: Vec = Vec::new(); - let mut had_persisted_row = false; - for pos in &positions { - let Some(item) = model.item(*pos) else { continue }; - let Ok(row_obj) = item.downcast::() else { - continue; - }; - if let Some(draft_id) = row_obj.draft_id() { - draft_ids.push(draft_id); - continue; - } - had_persisted_row = true; - if let Some(pair) = build_persisted_row_key(row_obj.cells_clone(), &pk_indices) { - snapshot.push(pair); + let mut had_unkeyed_row = false; + for row in positions + .iter() + .filter_map(|position| selection.item(*position).and_downcast::()) + { + match row.id() { + GridRowId::Draft(draft_id) => draft_ids.push(*draft_id), + GridRowId::Persisted(key) => { + snapshot.push((key.clone(), row.cells().map(|cells| cells.to_vec()).unwrap_or_default())); + } + GridRowId::Unkeyed | GridRowId::Result => had_unkeyed_row = true, } } // A selection of drafts only needs no key. - if had_persisted_row && pk_indices.is_empty() { + if had_unkeyed_row { fx.output(BrowseTabOutput::ShowSelectionAlert { title: crate::i18n::gettext("Cannot delete"), body: crate::i18n::gettext("This table has no primary key, so editing is disabled."), @@ -1845,6 +1773,27 @@ impl BrowseModel { dialog.present(Some(&self.inner_stack)); } + /// One row as an INSERT for the server the tab is connected to, + /// pending edits included. + fn copy_row_as_insert(&self, row: &RowObject, fx: &mut Effects) { + let Some(dialect) = self.session.current_dialect() else { + fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( + "Connect to a database first.", + ))); + return; + }; + let cells = self.grid_context().effective_cells(row); + match tablepro_core::export::render_insert(&*dialect, &self.table_ref(), &self.current_columns, &cells) { + Ok(sql) => fx.output(BrowseTabOutput::CopyToClipboard(sql)), + Err(error) => { + tracing::debug!(%error, "a row could not be written as an INSERT"); + fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( + "This row has a value that can't be written as SQL", + ))); + } + } + } + /// Same renderer as the context menu's Copy as > Rows: one /// selection cannot produce two different clipboard payloads. fn copy_selected_rows(&self, fx: &mut Effects) { @@ -1855,14 +1804,10 @@ impl BrowseModel { if positions.is_empty() { return; } - let Some(model) = selection.model() else { - return; - }; let ctx = self.grid_context(); let rows: Vec> = positions .iter() - .filter_map(|pos| model.item(*pos)) - .filter_map(|item| item.downcast::().ok()) + .filter_map(|position| selection.item(*position).and_downcast::()) .map(|row| ctx.effective_cells(&row)) .collect(); if rows.is_empty() { @@ -1886,26 +1831,21 @@ fn qualified(schema: Option<&str>, table: &str) -> String { } fn apply_visual_change(store: >k::gio::ListStore, change: &VisualChange) { - let draft_at = |draft_id: u64| { - (0..store.n_items()).find_map(|position| { - let row = store.item(position).and_downcast::()?; - (row.draft_id() == Some(draft_id)).then_some((position, row)) - }) - }; match change { - VisualChange::InsertDraft { draft_id, values } => { - store.insert(0, &RowObject::new_draft(*draft_id, values.clone())); + VisualChange::InsertDraft { draft_id } => { + store.insert(0, &RowObject::draft(*draft_id)); } VisualChange::RemoveDraft { draft_id } => { - if let Some((position, _)) = draft_at(*draft_id) { + let position = (0..store.n_items()).find(|position| { + store + .item(*position) + .and_downcast::() + .is_some_and(|row| row.draft_id() == Some(*draft_id)) + }); + if let Some(position) = position { store.remove(position); } } - VisualChange::SetDraftCell { draft_id, col, value } => { - if let Some((_, row)) = draft_at(*draft_id) { - row.set_cell(*col, value.clone()); - } - } } } @@ -1928,17 +1868,15 @@ fn normalize_single_line_input(text: &str) -> String { replaced.split_whitespace().collect::>().join(" ") } -/// Build a `(RowKey, cells)` pair for a persisted row given its full -/// cell slice and the table's PK column indices. Returns `None` when -/// pk_indices is empty (no PK), any index is out of range, or -/// `RowKey::from_pk_values` rejects the values. -fn build_persisted_row_key(cells: Vec, pk_indices: &[usize]) -> Option<(RowKey, Vec)> { +/// A fetched row's key from its primary key cells, in the table's +/// column order. `None` when the table has no primary key or the row is +/// too short to hold it. +fn persisted_key(cells: &[Value], pk_indices: &[usize]) -> Option { let pk_values: Vec = pk_indices .iter() .map(|&i| cells.get(i).cloned()) .collect::>()?; - let key = RowKey::from_pk_values(&pk_values)?; - Some((key, cells)) + RowKey::from_pk_values(&pk_values) } /// Update the selection-count badge in response to a `MultiSelection` diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 6024817be9..dab27880f3 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -12,10 +12,11 @@ use tablepro_session::fakes::FakeEstablish; use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeReply}; use super::{ - BrowseTab, BrowseTabInit, BrowseTabInput, apply_visual_change, build_persisted_row_key, format_thousands, - parse_input_for_column, + BrowseTab, BrowseTabInit, BrowseTabInput, apply_visual_change, format_thousands, parse_input_for_column, + persisted_key, }; use crate::services::change_tracker::RowKey; +use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_delta::VisualChange; use crate::test_support::{FakeSession, drain_main_context, wait_until}; use crate::ui::grid::RowObject; @@ -215,15 +216,14 @@ fn null_sentinel_typed_literally_is_text() { } #[test] -fn build_pk_single_column() { +fn a_fetched_row_is_keyed_by_its_primary_key_cells() { let cells = vec![Value::Int(42), Value::Text("alice".into())]; - let (key, returned) = build_persisted_row_key(cells.clone(), &[0]).expect("valid PK"); - assert!(matches!(key, RowKey::Persisted(_))); - assert_eq!(returned, cells); + + assert_eq!(persisted_key(&cells, &[0]), RowKey::from_pk_values(&[Value::Int(42)])); } #[test] -fn build_pk_multi_column_preserves_index_order() { +fn a_composite_key_keeps_the_key_order() { // Two tables with the same columns in a different key order must not // produce colliding keys. let cells = vec![ @@ -231,36 +231,29 @@ fn build_pk_multi_column_preserves_index_order() { Value::Text("ignored".into()), Value::Int(7), ]; - let (key, _) = build_persisted_row_key(cells, &[2, 0]).expect("composite PK"); - let RowKey::Persisted(kv) = key else { - panic!("expected Persisted") - }; - assert_eq!(kv.len(), 2); -} - -#[test] -fn build_pk_empty_indices_returns_none() { - let cells = vec![Value::Int(1)]; - assert!(build_persisted_row_key(cells, &[]).is_none()); -} -#[test] -fn build_pk_index_out_of_range_returns_none() { - let cells = vec![Value::Int(1), Value::Text("x".into())]; - assert!(build_persisted_row_key(cells, &[5]).is_none()); + assert_eq!( + persisted_key(&cells, &[2, 0]), + RowKey::from_pk_values(&[Value::Int(7), Value::Text("eu-west".into())]) + ); } #[test] -fn build_pk_partial_out_of_range_returns_none() { - let cells = vec![Value::Int(1)]; - assert!(build_persisted_row_key(cells, &[0, 1]).is_none()); +fn a_row_without_its_key_cells_is_unkeyed() { + assert!( + persisted_key(&[Value::Int(1)], &[]).is_none(), + "a table with no primary key" + ); + assert!(persisted_key(&[Value::Int(1)], &[5]).is_none()); + assert!(persisted_key(&[Value::Int(1)], &[0, 1]).is_none()); } #[test] -fn build_pk_with_null_components_is_allowed() { - let cells = vec![Value::Null, Value::Text("x".into())]; - let (key, _) = build_persisted_row_key(cells, &[0]).expect("Null PK is valid"); - assert!(matches!(key, RowKey::Persisted(_))); +fn a_null_key_cell_still_names_the_row() { + assert!(matches!( + persisted_key(&[Value::Null, Value::Text("x".into())], &[0]), + Some(RowKey::Persisted(_)) + )); } fn row_at(store: >k4::gio::ListStore, position: u32) -> RowObject { @@ -268,27 +261,12 @@ fn row_at(store: >k4::gio::ListStore, position: u32) -> RowObject { } #[test] -fn visual_changes_add_update_and_remove_draft_rows() { +fn visual_changes_add_and_remove_draft_rows() { let store = gtk4::gio::ListStore::new::(); - store.append(&RowObject::new(vec![Value::Int(1)])); + store.append(&RowObject::persisted(None, vec![Value::Int(1)].into())); - apply_visual_change( - &store, - &VisualChange::InsertDraft { - draft_id: 4, - values: vec![Value::Null], - }, - ); - apply_visual_change( - &store, - &VisualChange::SetDraftCell { - draft_id: 4, - col: 0, - value: Value::Int(9), - }, - ); + apply_visual_change(&store, &VisualChange::InsertDraft { draft_id: 4 }); assert_eq!(row_at(&store, 0).draft_id(), Some(4)); - assert_eq!(row_at(&store, 0).cell_value(0), Value::Int(9)); apply_visual_change(&store, &VisualChange::RemoveDraft { draft_id: 4 }); assert_eq!(store.n_items(), 1); @@ -337,6 +315,7 @@ fn serving_table() -> ConnectionScript { struct Harness { tab: Option, + tracker: TabTrackerHandle, others: Vec, session: Arc, connection: Arc, @@ -361,9 +340,11 @@ impl Harness { let runtime = crate::test_support::test_runtime(); let fake = FakeSession::start(script, reconnects, runtime.tasks()); let root = tempfile::tempdir().expect("tempdir"); - let tab = filtered_users_tab(&fake.session, &runtime, &root, saved); + let tracker = TabTrackerHandle::default(); + let tab = filtered_users_tab(&fake.session, &runtime, &root, saved, tracker.clone()); Self { tab: Some(tab), + tracker, others: Vec::new(), session: fake.session.clone(), connection: fake.connection.clone(), @@ -395,10 +376,27 @@ impl Harness { fn visible_page(&self) -> Option { self.tab().imp_stack().visible_child_name().map(|name| name.to_string()) } + + /// The row the grid shows at `position`, drafts included. + fn grid_row(&self, position: u32) -> RowObject { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = self.tab().imp().model.borrow(); + let selection = model + .as_ref() + .and_then(|model| model.current_selection.clone()) + .expect("the grid is built"); + selection.item(position).and_downcast::().expect("a row") + } } fn users_tab(session: &Arc, runtime: &crate::runtime::AppRuntime, root: &tempfile::TempDir) -> BrowseTab { - filtered_users_tab(session, runtime, root, FilterSet::default()) + filtered_users_tab( + session, + runtime, + root, + FilterSet::default(), + TabTrackerHandle::default(), + ) } fn filtered_users_tab( @@ -406,13 +404,14 @@ fn filtered_users_tab( runtime: &crate::runtime::AppRuntime, root: &tempfile::TempDir, saved: FilterSet, + tracker: TabTrackerHandle, ) -> BrowseTab { let tasks = runtime.tasks(); let connection_id = uuid::Uuid::new_v4(); let filter_settings = crate::persistence::FilterSettingsStore::load(root.path().join("filters.json"), &tasks); filter_settings.set_filters(connection_id, None, "users", saved); BrowseTab::new(BrowseTabInit { - tracker: crate::services::tab_tracker_handle::TabTrackerHandle::default(), + tracker, schema: None, table: "users".to_owned(), connection_id: Some(connection_id), @@ -790,3 +789,56 @@ fn browse_tab_and_filter_strip_freed_after_drop() { assert!(strip.upgrade().is_none(), "the filter strip outlived its tab"); assert!(tab.upgrade().is_none(), "the browse tab outlived its last reference"); } + +#[gtk4::test] +fn duplicate_row_copies_pending_edits_of_persisted_row() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let ada = harness.grid_row(0); + + harness.tab().send(BrowseTabInput::GridCellEdited { + row: ada.clone(), + column: 1, + input: "Ada Lovelace".to_owned(), + }); + harness.tab().send(BrowseTabInput::DuplicateRow { row: ada }); + + let drafts = harness.tracker.drafts(); + assert_eq!(drafts.len(), 1, "{drafts:?}"); + let texts: Vec> = drafts[0] + .values + .iter() + .map(tablepro_core::export::value_to_text) + .collect(); + assert_eq!( + texts, + vec![None, Some("Ada Lovelace".to_owned())], + "the duplicate left out the row's pending edit" + ); +} + +#[gtk4::test] +fn copy_row_as_insert_uses_row_below_draft() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let copied = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = copied.clone(); + harness + .tab() + .connect_copy_to_clipboard(move |_, text| sink.borrow_mut().push(text)); + + harness.tab().send(BrowseTabInput::InsertRow); + let below_draft = harness.grid_row(1); + harness + .tab() + .send(BrowseTabInput::GridCopyRowAsInsert { row: below_draft }); + + assert_eq!( + *copied.borrow(), + ["INSERT INTO \"users\" (\"id\", \"name\") VALUES (0, 'Ada');".to_owned()] + ); +} diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index a9f5e8f382..c819d456e8 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -13,7 +13,7 @@ use super::EditorDeps; use super::editor_session_slot::EditorSessionSlot; use super::results_pane::ResultsPane; use super::run_log::RunLog; -use crate::ui::grid::GridMsg; +use crate::ui::grid::GridSink; use crate::ui::handler_guard::HandlerGuard; /// A run in flight: its cancel switch, and the loop that brings its @@ -45,7 +45,7 @@ pub struct SqlEditor { pub slot: Arc, pub running: RefCell>, pub log: RefCell>, - pub grid_sender: OnceCell>, + pub grid_sink: OnceCell, /// Whether the last run left a transaction open, as last reported. pub transaction_dirty: Cell, pub shut_down: Cell, diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 9362a14b5d..3aad36e2da 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -41,7 +41,7 @@ use self::imp::RunningScript; use self::run_log::{RunEvent, RunLog}; use self::run_scope::ScopedText; use self::script_runner::{ScriptRun, plan_batches, run_in_tab}; -use super::grid::GridMsg; +use super::grid::{GridMsg, GridSink}; use super::handler_guard::HandlerGuard; use crate::services::active_session::ActiveSession; @@ -424,11 +424,11 @@ impl SqlEditor { fn result_page(&self, index: u32) -> Option { let imp = self.imp(); - let grid_sender = imp.grid_sender.get()?; + let grid_sink = imp.grid_sink.get()?; let log = imp.log.borrow(); let entry = log.as_ref()?.entries().get(usize::try_from(index).ok()?)?; let weak = self.downgrade(); - Some(result_widgets::entry_widget(entry, grid_sender, move |position| { + Some(result_widgets::entry_widget(entry, grid_sink, move |position| { if let Some(editor) = weak.upgrade() { editor.go_to_char(position); } @@ -592,26 +592,23 @@ impl SqlEditor { self.imp() .results .set_page_builder(move |index| weak.upgrade().and_then(|editor| editor.result_page(index))); - let (sender, receiver) = relm4::channel::(); - let _ = self.imp().grid_sender.set(sender); let weak = self.downgrade(); - glib::spawn_future_local(async move { - while let Some(message) = receiver.recv().await { - let Some(editor) = weak.upgrade() else { - return; - }; - match message { - GridMsg::CopyToClipboard(text) => editor.emit_by_name::<()>("copy-to-clipboard", &[&text]), - GridMsg::ShowToast(text) => editor.emit_by_name::<()>("toast", &[&text]), - GridMsg::ExportResults(result) => { - let name = export_name_for_query(&editor.selected_preview()); - let payload = glib::BoxedAnyObject::new((result, name)); - editor.emit_by_name::<()>("export-results", &[&payload]); - } - _ => {} + let sink: GridSink = std::rc::Rc::new(move |message| { + let Some(editor) = weak.upgrade() else { + return; + }; + match message { + GridMsg::CopyToClipboard(text) => editor.emit_by_name::<()>("copy-to-clipboard", &[&text]), + GridMsg::ShowToast(text) => editor.emit_by_name::<()>("toast", &[&text]), + GridMsg::ExportResults(result) => { + let name = export_name_for_query(&editor.selected_preview()); + let payload = glib::BoxedAnyObject::new((result, name)); + editor.emit_by_name::<()>("export-results", &[&payload]); } + _ => {} } }); + let _ = self.imp().grid_sink.set(sink); } /// The statement behind the result on screen, to name its export. diff --git a/linux/crates/app/src/ui/editor/result_widgets.rs b/linux/crates/app/src/ui/editor/result_widgets.rs index 049c6b2ea7..89c175b456 100644 --- a/linux/crates/app/src/ui/editor/result_widgets.rs +++ b/linux/crates/app/src/ui/editor/result_widgets.rs @@ -2,25 +2,22 @@ use gtk4::prelude::*; use libadwaita as adw; use super::run_log::{EntryKind, RunLogEntry}; -use crate::ui::grid::{GridMsg, TabGridContext, build_column_view}; +use crate::ui::grid::{GridSink, RowObject, TabGridContext, build_column_view}; use crate::ui::icons; /// The widget a results list line shows. `go_to_error` puts the editor /// cursor where the server says a statement went wrong. -pub fn entry_widget( - entry: &RunLogEntry, - grid_sender: &relm4::Sender, - go_to_error: impl Fn(usize) + 'static, -) -> gtk4::Widget { +pub fn entry_widget(entry: &RunLogEntry, grid_sink: &GridSink, go_to_error: impl Fn(usize) + 'static) -> gtk4::Widget { match &entry.kind { EntryKind::ResultSet { result, total_rows } => { + let rows = result.rows.iter().map(|cells| RowObject::result(cells.clone().into())); let (column_view, _selection) = build_column_view( result, + rows, &[], - grid_sender.clone(), - false, - None, + grid_sink.clone(), None, + false, TabGridContext::default(), ); let scrolled = gtk4::ScrolledWindow::builder() @@ -145,8 +142,8 @@ fn row_count_text(rows: usize, truncated: bool, total: Option) -> String { mod tests { use super::*; - fn sender() -> relm4::Sender { - relm4::channel::().0 + fn sink() -> GridSink { + std::rc::Rc::new(|_| {}) } #[gtk4::test] @@ -160,7 +157,7 @@ mod tests { preview: String::new(), }; - let widget = entry_widget(&entry, &sender(), |_| {}); + let widget = entry_widget(&entry, &sink(), |_| {}); let page = widget.downcast::().expect("a status page"); assert_eq!( @@ -182,7 +179,7 @@ mod tests { let reached = std::rc::Rc::new(std::cell::Cell::new(None)); let seen = reached.clone(); - let widget = entry_widget(&entry, &sender(), move |position| seen.set(Some(position))); + let widget = entry_widget(&entry, &sink(), move |position| seen.set(Some(position))); let page = widget.downcast::().expect("a status page"); let button = page.child().and_downcast::().expect("Go to Error"); button.emit_clicked(); diff --git a/linux/crates/app/src/ui/grid/cell_editability.rs b/linux/crates/app/src/ui/grid/cell_editability.rs new file mode 100644 index 0000000000..6b0eb04fce --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_editability.rs @@ -0,0 +1,148 @@ +use tablepro_core::ColumnInfo; +use tablepro_core::edit::read_only_reason; +use tablepro_core::meta::RowIdentity; + +use super::{CellLock, GridRowId}; + +/// Whether one cell of one row takes an edit. A draft is a new row, so +/// it may set the key and auto-increment columns a saved row may not. +pub fn cell_editability( + column: &ColumnInfo, + row: &GridRowId, + identity: &RowIdentity, + read_only: bool, +) -> Result<(), CellLock> { + match row { + GridRowId::Result => return Err(CellLock::ResultRow), + GridRowId::Unkeyed => return Err(CellLock::UnkeyedRow), + GridRowId::Persisted(_) | GridRowId::Draft(_) => {} + } + if read_only { + return Err(CellLock::ReadOnlySession); + } + if column.is_generated { + return Err(CellLock::Generated); + } + if let Some(reason) = read_only_reason(&column.column_type.kind) { + return Err(CellLock::Value(reason)); + } + if matches!(row, GridRowId::Persisted(_)) { + if names_rows(column, identity) { + return Err(CellLock::KeyColumn); + } + if column.is_auto_increment { + return Err(CellLock::AutoIncrement); + } + } + Ok(()) +} + +/// Whether some row of this column can be edited, which decides the +/// widget the column's cells are built with. A draft has the fewest +/// locks, so a column a draft cannot edit no row can. +pub fn column_takes_edits(column: &ColumnInfo, identity: &RowIdentity, read_only: bool) -> bool { + cell_editability(column, &GridRowId::Draft(0), identity, read_only).is_ok() +} + +fn names_rows(column: &ColumnInfo, identity: &RowIdentity) -> bool { + match identity { + RowIdentity::UniqueKey { columns } => columns.contains(&column.name), + RowIdentity::SortingKey { expressions } => expressions.contains(&column.name), + RowIdentity::EngineRowId(_) | RowIdentity::Unordered => false, + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnType}; + use tablepro_core::edit::ReadOnlyReason; + use tablepro_core::meta::RowIdentity; + use tablepro_core::{ColumnInfo, Value}; + + use super::{cell_editability, column_takes_edits}; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::{CellLock, GridRowId}; + + fn column(name: &str, data_type: &str) -> ColumnInfo { + let kind = tablepro_core::column::classify_type_name(data_type); + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType { + kind, + ..ColumnType::untyped_text(data_type) + }, + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn keyed_by_id() -> RowIdentity { + RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + } + } + + fn persisted() -> GridRowId { + GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")) + } + + #[test] + fn cell_editability_matrix() { + let identity = keyed_by_id(); + let id = column("id", "integer"); + let name = column("name", "text"); + let mut serial = column("seq", "integer"); + serial.is_auto_increment = true; + let mut total = column("total", "integer"); + total.is_generated = true; + let blob = column("data", "bytea"); + let draft = GridRowId::Draft(0); + let check = + |column: &ColumnInfo, row: &GridRowId, read_only: bool| cell_editability(column, row, &identity, read_only); + + assert_eq!(check(&name, &persisted(), false), Ok(())); + assert_eq!(check(&id, &persisted(), false), Err(CellLock::KeyColumn)); + assert_eq!(check(&id, &draft, false), Ok(()), "a draft sets its own key"); + assert_eq!(check(&total, &draft, false), Err(CellLock::Generated)); + assert_eq!(check(&total, &persisted(), false), Err(CellLock::Generated)); + assert_eq!(check(&serial, &persisted(), false), Err(CellLock::AutoIncrement)); + assert_eq!(check(&serial, &draft, false), Ok(())); + assert_eq!(check(&name, &GridRowId::Unkeyed, false), Err(CellLock::UnkeyedRow)); + assert_eq!(check(&name, &GridRowId::Result, false), Err(CellLock::ResultRow)); + assert_eq!(check(&name, &persisted(), true), Err(CellLock::ReadOnlySession)); + assert_eq!(check(&name, &draft, true), Err(CellLock::ReadOnlySession)); + assert_eq!(check(&blob, &draft, false), Err(CellLock::Value(ReadOnlyReason::Bytes))); + } + + #[test] + fn a_sorting_key_column_is_locked_on_saved_rows() { + let identity = RowIdentity::SortingKey { + expressions: vec!["day".to_owned()], + }; + + assert_eq!( + cell_editability(&column("day", "date"), &persisted(), &identity, false), + Err(CellLock::KeyColumn) + ); + assert_eq!( + cell_editability(&column("hits", "integer"), &persisted(), &identity, false), + Ok(()) + ); + } + + #[test] + fn a_column_no_row_can_edit_is_built_read_only() { + let identity = keyed_by_id(); + let mut total = column("total", "integer"); + total.is_generated = true; + + assert!(column_takes_edits(&column("id", "integer"), &identity, false)); + assert!(!column_takes_edits(&total, &identity, false)); + assert!(!column_takes_edits(&column("data", "blob"), &identity, false)); + assert!(!column_takes_edits(&column("name", "text"), &identity, true)); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_lock.rs b/linux/crates/app/src/ui/grid/cell_lock.rs new file mode 100644 index 0000000000..dd58514572 --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_lock.rs @@ -0,0 +1,16 @@ +use tablepro_core::edit::ReadOnlyReason; + +/// Why one cell of one row cannot be edited. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CellLock { + /// The column's values have no text form to type. + Value(ReadOnlyReason), + /// The row is named by this column, so changing it in place would + /// lose the row it names. + KeyColumn, + Generated, + AutoIncrement, + UnkeyedRow, + ResultRow, + ReadOnlySession, +} diff --git a/linux/crates/app/src/ui/grid/cell_popovers.rs b/linux/crates/app/src/ui/grid/cell_popovers.rs index ebfa3fc98f..a87a4065d9 100644 --- a/linux/crates/app/src/ui/grid/cell_popovers.rs +++ b/linux/crates/app/src/ui/grid/cell_popovers.rs @@ -3,55 +3,73 @@ use gtk4::prelude::*; use gtk4::{self as gtk, glib}; use sourceview5::prelude::*; -use super::GridMsg; -use super::cell_editor::CellEditor; -use super::widget_slot::{POPOVER_SLOT, POSITION_SLOT}; +use super::{GridCell, GridMsg, GridSink}; -pub(super) fn show_calendar_popover(label: &CellEditor, col_index: usize, sender: &relm4::Sender) { +fn current_text(cell: &GridCell) -> String { + cell.editor() + .map(|editor| editor.text().to_string()) + .unwrap_or_default() +} + +/// Parent the popover to the cell and keep it there until it closes. +fn present(cell: &GridCell, popover: >k::Popover) { + popover.set_parent(cell); + cell.set_popover(popover); + // The cell holds the popover in its slot and as its child, so the + // popover holds the cell weakly and itself not at all. + let cell_for_close = cell.downgrade(); + popover.connect_closed(move |popover| { + popover.unparent(); + if let Some(cell) = cell_for_close.upgrade() { + cell.release_popover(popover); + } + }); + popover.popup(); +} + +/// The popover is closed before the tab hears of the value, so an edit +/// the tab redraws the row for never meets an open popover. +fn commit(cell: &GridCell, popover: >k::Popover, input: String, sink: &GridSink) { + // Drawn now rather than after the tracker's round trip. + if let Some(editor) = cell.editor() { + editor.set_text(&input); + } + let row = cell.row(); + popover.popdown(); + if let Some(row) = row { + sink(GridMsg::CellEdited { + row, + column: cell.column(), + input, + }); + } +} + +pub(super) fn show_calendar_popover(cell: &GridCell, sink: &GridSink) { let calendar = gtk::Calendar::new(); - if let Ok(parsed) = chrono::NaiveDate::parse_from_str(label.text().as_str(), "%Y-%m-%d") + if let Ok(parsed) = chrono::NaiveDate::parse_from_str(¤t_text(cell), "%Y-%m-%d") && let Ok(dt) = glib::DateTime::from_local(parsed.year(), parsed.month() as i32, parsed.day() as i32, 0, 0, 0.0) { calendar.set_date(&dt); } let popover = gtk::Popover::builder().child(&calendar).build(); - popover.set_parent(label); - POPOVER_SLOT.set(label, popover.clone()); - - let label_for_cal = label.downgrade(); + let cell_for_cal = cell.downgrade(); let popover_for_cal = popover.downgrade(); - let sender_for_cal = sender.clone(); + let sink = sink.clone(); calendar.connect_day_selected(move |c| { - let (Some(label_for_cal), Some(popover_for_cal)) = (label_for_cal.upgrade(), popover_for_cal.upgrade()) else { + let (Some(cell), Some(popover)) = (cell_for_cal.upgrade(), popover_for_cal.upgrade()) else { return; }; let dt = c.date(); let formatted = format!("{:04}-{:02}-{:02}", dt.year(), dt.month(), dt.day_of_month()); - let position = POSITION_SLOT.get(&label_for_cal).unwrap_or(0); - // Drawn now rather than after the tracker's round trip. - label_for_cal.set_text(&formatted); - sender_for_cal - .send(GridMsg::CellEdited { - row_position: position, - col_index, - new_value: formatted, - }) - .ok(); - popover_for_cal.popdown(); + commit(&cell, &popover, formatted, &sink); }); - - install_popover_close_cleanup(label, &popover); - popover.popup(); + present(cell, &popover); } -pub(super) fn show_spin_button_popover( - label: &CellEditor, - col_index: usize, - sender: &relm4::Sender, - is_float: bool, -) { - let current_text = label.text().to_string(); +pub(super) fn show_spin_button_popover(cell: &GridCell, sink: &GridSink, is_float: bool) { + let current_text = current_text(cell); let (initial, lower, upper, step, digits) = if is_float { let val = current_text.parse::().unwrap_or(0.0); (val, f64::MIN, f64::MAX, 0.1_f64, 6_u32) @@ -65,16 +83,11 @@ pub(super) fn show_spin_button_popover( spin.set_width_chars(20); let popover = gtk::Popover::builder().child(&spin).build(); - popover.set_parent(label); - POPOVER_SLOT.set(label, popover.clone()); - - let label_for_commit = label.downgrade(); + let cell_for_commit = cell.downgrade(); let popover_for_commit = popover.downgrade(); - let sender_for_commit = sender.clone(); + let sink = sink.clone(); spin.connect_activate(move |s| { - let (Some(label_for_commit), Some(popover_for_commit)) = - (label_for_commit.upgrade(), popover_for_commit.upgrade()) - else { + let (Some(cell), Some(popover)) = (cell_for_commit.upgrade(), popover_for_commit.upgrade()) else { return; }; // f64's Display claims no more precision than the value has. @@ -83,31 +96,20 @@ pub(super) fn show_spin_button_popover( } else { format!("{}", s.value() as i64) }; - let position = POSITION_SLOT.get(&label_for_commit).unwrap_or(0); - label_for_commit.set_text(&formatted); - sender_for_commit - .send(GridMsg::CellEdited { - row_position: position, - col_index, - new_value: formatted, - }) - .ok(); - popover_for_commit.popdown(); + commit(&cell, &popover, formatted, &sink); }); - - install_popover_close_cleanup(label, &popover); - popover.popup(); + present(cell, &popover); spin.grab_focus(); } /// Saves through a button, since Return inserts a newline in a /// multi-line buffer. -pub(super) fn show_json_popover(label: &CellEditor, col_index: usize, sender: &relm4::Sender) { +pub(super) fn show_json_popover(cell: &GridCell, sink: &GridSink) { let buffer = sourceview5::Buffer::new(None); if let Some(lang) = sourceview5::LanguageManager::default().language("json") { buffer.set_language(Some(&lang)); } - buffer.set_text(label.text().as_str()); + buffer.set_text(¤t_text(cell)); let view = sourceview5::View::with_buffer(&buffer); view.set_show_line_numbers(true); @@ -148,8 +150,6 @@ pub(super) fn show_json_popover(label: &CellEditor, col_index: usize, sender: &r container.append(&button_box); let popover = gtk::Popover::builder().child(&container).build(); - popover.set_parent(label); - POPOVER_SLOT.set(label, popover.clone()); let popover_for_cancel = popover.downgrade(); cancel_button.connect_clicked(move |_| { @@ -158,58 +158,33 @@ pub(super) fn show_json_popover(label: &CellEditor, col_index: usize, sender: &r } }); - let label_for_commit = label.downgrade(); + let cell_for_commit = cell.downgrade(); let popover_for_commit = popover.downgrade(); - let sender_for_commit = sender.clone(); + let sink = sink.clone(); let buffer_for_commit = buffer.clone(); save_button.connect_clicked(move |_| { - let (Some(label_for_commit), Some(popover_for_commit)) = - (label_for_commit.upgrade(), popover_for_commit.upgrade()) - else { + let (Some(cell), Some(popover)) = (cell_for_commit.upgrade(), popover_for_commit.upgrade()) else { return; }; let start = buffer_for_commit.start_iter(); let end = buffer_for_commit.end_iter(); let text = buffer_for_commit.text(&start, &end, true).to_string(); - let position = POSITION_SLOT.get(&label_for_commit).unwrap_or(0); // The tab validates the JSON and refuses a malformed value with // a toast. - label_for_commit.set_text(&text); - sender_for_commit - .send(GridMsg::CellEdited { - row_position: position, - col_index, - new_value: text, - }) - .ok(); - popover_for_commit.popdown(); + commit(&cell, &popover, text, &sink); }); - install_popover_close_cleanup(label, &popover); - popover.popup(); + present(cell, &popover); view.grab_focus(); } -fn install_popover_close_cleanup(label: &CellEditor, popover: >k::Popover) { - // The cell holds the popover in its slot and as its child, so the - // popover holds the cell weakly and itself not at all. - let label_for_close = label.downgrade(); - popover.connect_closed(move |p| { - p.unparent(); - if let Some(label) = label_for_close.upgrade() { - POPOVER_SLOT.take(&label); - } - }); -} - #[cfg(test)] mod tests { use gtk4::prelude::*; use gtk4::{self as gtk, glib}; - use super::{ - CellEditor, GridMsg, POPOVER_SLOT, show_calendar_popover, show_json_popover, show_spin_button_popover, - }; + use super::{show_calendar_popover, show_json_popover, show_spin_button_popover}; + use crate::ui::grid::{CellEditor, GridCell, GridSink}; #[gtk4::test] fn calendar_selects_cell_date() { @@ -259,12 +234,12 @@ mod tests { /// Another test's window can take the focus and close an autohide /// popover before it opens, so a dismissed one is shown again. - fn open_popover(editor: PopoverEditor, cell: &CellEditor, sender: &relm4::Sender) -> Option { + fn open_popover(editor: PopoverEditor, cell: &GridCell, sink: &GridSink) -> Option { for _ in 0..5 { match editor { - PopoverEditor::Date => show_calendar_popover(cell, 0, sender), - PopoverEditor::Int => show_spin_button_popover(cell, 0, sender, false), - PopoverEditor::Json => show_json_popover(cell, 0, sender), + PopoverEditor::Date => show_calendar_popover(cell, sink), + PopoverEditor::Int => show_spin_button_popover(cell, sink, false), + PopoverEditor::Json => show_json_popover(cell, sink), } let popover = crate::test_support::first_descendant_of_type::(cell)?; let settled = crate::test_support::wait_until(std::time::Duration::from_secs(5), || { @@ -276,7 +251,7 @@ mod tests { if popover.parent().is_some() { popover.unparent(); } - POPOVER_SLOT.take(cell); + cell.take_popover(); } None } @@ -288,25 +263,24 @@ mod tests { let window = gtk::Window::new(); window.present(); for editor in [PopoverEditor::Date, PopoverEditor::Int, PopoverEditor::Json] { - let (sender, _receiver) = relm4::channel::(); - let cell = CellEditor::new(); - cell.set_text("x"); + let sink: GridSink = std::rc::Rc::new(|_| {}); + let cell = GridCell::new(); + let text = CellEditor::new(); + text.set_text("x"); + cell.set_content(&text); window.set_child(Some(&cell)); wait_until(timeout, || cell.is_mapped()).expect("the cell is drawn"); - let popover = open_popover(editor, &cell, &sender).expect("the editor popover opens"); + let popover = open_popover(editor, &cell, &sink).expect("the editor popover opens"); commit_popover(editor, &popover); wait_until(timeout, || popover.parent().is_none()).expect("the popover closes"); - assert_ne!(cell.text(), "x", "{editor:?} did not commit"); - assert!( - POPOVER_SLOT.take(&cell).is_none(), - "{editor:?} left its popover in the cell" - ); + assert_ne!(text.text(), "x", "{editor:?} did not commit"); + assert!(!cell.is_editing(), "{editor:?} left its popover in the cell"); // Only the cell is checked: GTK itself keeps a closed autohide // popover alive once a child of it has taken focus. let freed = cell.downgrade(); window.set_child(None::<>k::Widget>); - drop((cell, popover)); + drop((cell, text, popover)); crate::test_support::drain_main_context(); assert!(freed.upgrade().is_none(), "{editor:?} kept its cell alive"); diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index 3a940aa5fa..2e94a25a13 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -4,14 +4,15 @@ use gtk4::{self as gtk, glib}; use tablepro_core::column::ResultColumn; use tablepro_core::{ColumnInfo, QueryResult, Value}; -use super::cell_editor::CellEditor; +use super::cell_editability::column_takes_edits; use super::cell_editor_kind::{CellEditorKind, classify_editor_kind, is_bool_type}; use super::context_menu::{CellMenuTarget, attach_cell_gesture}; use super::display_text::{auto_filled_sentinel, editable_null_sentinel, value_to_display_text, value_to_edit_text}; use super::grid_menus::{GridMenuInit, GridMenus, install_grid_context_menus}; use super::inline_edit::{install_edit_commit_handler, install_edit_triggers}; -use super::widget_slot::{COLUMN_SLOT, POPOVER_SLOT, POSITION_SLOT, SNAPSHOT_SLOT, SUPPRESS_SLOT}; -use super::{GridMsg, RowObject, TabGridContext}; +use super::{ + CellEditor, CellLock, GridCell, GridMsg, GridRowId, GridSink, RowObject, TabGridContext, cell_editability, +}; const PENDING_CSS_CLASSES: &[&str] = &[ "tp-cell-modified", @@ -28,18 +29,20 @@ const WIDE_TABLE_THRESHOLD: usize = 8; /// About 14 characters at the default font size. const MIN_COLUMN_WIDTH_PX: i32 = 120; +/// `result` gives the columns and whether the read was cut short; +/// `rows` are what the grid shows, each carrying its own identity. pub fn build_column_view( result: &QueryResult, + rows: impl IntoIterator, schema_columns: &[ColumnInfo], - sender: relm4::Sender, - editable: bool, + sink: GridSink, sort: Option<(usize, bool)>, - sort_sender: Option>, + sortable: bool, tab_ctx: TabGridContext, ) -> (gtk::ColumnView, gtk::MultiSelection) { let store = gtk4::gio::ListStore::new::(); - for row in &result.rows { - store.append(&RowObject::new(row.clone())); + for row in rows { + store.append(&row); } let selection = gtk::MultiSelection::new(Some(store)); let column_view = gtk::ColumnView::builder() @@ -50,11 +53,10 @@ pub fn build_column_view( let grid_menus = install_grid_context_menus(GridMenuInit { column_view: &column_view, - sender: sender.clone(), + sink: sink.clone(), columns: result.columns.clone(), truncated: result.truncated, tab_ctx: tab_ctx.clone(), - editable, }); // Sharing the viewport by `expand` alone leaves a wide table with 20 @@ -67,29 +69,17 @@ pub fn build_column_view( }; let mut columns: Vec = Vec::with_capacity(result.columns.len()); for (i, column) in result.columns.iter().enumerate() { - // The catalogue is the authority on what can be edited: it - // knows the keys, the generated columns and the defaults. Until - // it has loaded, the type alone decides, which is what a - // result column can say. - let cell_editable = editable - && match schema_columns.get(i) { - Some(catalog) => is_cell_editable(catalog), - None => !is_bytes_kind(&column.column_type.kind), - }; - let col = build_column( - column, - i, - cell_editable, - schema_columns - .get(i) - .is_some_and(|catalog| catalog.is_auto_increment || catalog.is_generated), - sender.clone(), - sort_sender.clone(), - tab_ctx.clone(), + let col = build_column(ColumnInit { + info: column, + idx: i, + catalog: schema_columns.get(i).cloned(), + sink: sink.clone(), + sortable, + tab_ctx: tab_ctx.clone(), default_min_width, - column_view.downgrade(), - grid_menus.clone(), - ); + column_view: column_view.downgrade(), + grid_menus: grid_menus.clone(), + }); column_view.append_column(&col); columns.push(col); } @@ -107,7 +97,7 @@ pub fn build_column_view( column_view.sort_by_column(Some(col), direction); } - if let Some(app_sender) = sort_sender + if sortable && let Some(view_sorter) = column_view .sorter() .and_then(|s| s.downcast::().ok()) @@ -117,18 +107,15 @@ pub fn build_column_view( // sorter's post-click pair, which the receiver drops when it // already matches. let dispatch = { - let app_sender = app_sender.clone(); + let sink = sink.clone(); let columns = columns.clone(); move |sorter: >k::ColumnViewSorter| { let Some(active) = sorter.primary_sort_column() else { return; }; let ascending = matches!(sorter.primary_sort_order(), gtk::SortType::Ascending); - for (idx, col) in columns.iter().enumerate() { - if col == &active { - app_sender.send(GridMsg::SortChanged(idx, ascending)).ok(); - break; - } + if let Some(idx) = columns.iter().position(|col| col == &active) { + sink(GridMsg::SortChanged(idx, ascending)); } } }; @@ -142,178 +129,111 @@ pub fn build_column_view( (column_view, selection) } -#[expect( - clippy::too_many_arguments, - reason = "each argument is captured by a different column factory closure" -)] -fn build_column( - info: &ResultColumn, +/// How a column's cells are built. A column no row can edit gets the +/// plain label a read-only grid has. +#[derive(Clone, Copy)] +enum CellKind { + ReadOnly, + Toggle, + Text(CellEditorKind), +} + +struct ColumnInit<'a> { + info: &'a ResultColumn, idx: usize, - editable: bool, - // Whether the server fills this column in, so a draft row shows a - // placeholder rather than an empty cell the user should type in. - auto_filled: bool, - sender: relm4::Sender, - sort_sender: Option>, + /// `None` for a column the grid knows only from the result set. + catalog: Option, + sink: GridSink, + sortable: bool, tab_ctx: TabGridContext, default_min_width: Option, column_view: glib::WeakRef, grid_menus: GridMenus, -) -> gtk::ColumnViewColumn { +} + +fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { + let ColumnInit { + info, + idx, + catalog, + sink, + sortable, + tab_ctx, + default_min_width, + column_view, + grid_menus, + } = init; let factory = gtk::SignalListItemFactory::new(); - let column_data_type = info.column_type.name.clone(); - let column_name = info.name.clone(); - let accepts_empty = info.column_type.kind.accepts_empty_string(); - let column_view_for_setup = column_view.clone(); + let kind = match catalog.as_ref() { + Some(column) if tab_ctx.editable && column_takes_edits(column, &tab_ctx.identity, false) => { + if is_bool_type(&info.column_type.name) { + CellKind::Toggle + } else { + CellKind::Text(classify_editor_kind(&info.column_type.name)) + } + } + _ => CellKind::ReadOnly, + }; + // The server fills these in, so a draft row shows a placeholder + // rather than an empty cell the user should type in. + let auto_filled = catalog + .as_ref() + .is_some_and(|column| column.is_auto_increment || column.is_generated); + let target = CellMenuTarget { + text_editable: matches!(kind, CellKind::Text(_)), + accepts_empty: matches!(kind, CellKind::Text(_)) && info.column_type.kind.accepts_empty_string(), + }; + factory.connect_setup(move |_, item| { - let Some(item) = item.downcast_ref::() else { + let Some(cell) = item.downcast_ref::() else { return; }; - if !editable { - setup_readonly_cell(item, idx, column_name.clone(), &column_view_for_setup, &grid_menus); - return; - } - if is_bool_type(&column_data_type) { - setup_bool_cell( - item, - idx, - column_name.clone(), - sender.clone(), - &column_view_for_setup, - &grid_menus, - ); - } else { - let editor_kind = classify_editor_kind(&column_data_type); - setup_editable_cell( - item, - idx, - column_name.clone(), - sender.clone(), - editor_kind, - accepts_empty, - &column_view_for_setup, - &grid_menus, - ); + let grid_cell = GridCell::new(); + grid_cell.attach(cell, idx); + match kind { + CellKind::ReadOnly => setup_readonly_cell(&grid_cell), + CellKind::Toggle => setup_toggle_cell(&grid_cell, sink.clone()), + CellKind::Text(editor_kind) => setup_editable_cell(&grid_cell, sink.clone(), editor_kind), } + attach_cell_gesture(&grid_cell, &column_view, target, &grid_menus); + cell.set_child(Some(&grid_cell)); }); - let editable_for_bind = editable; - let column_auto_filled = auto_filled; let tab_ctx_for_bind = tab_ctx.clone(); factory.connect_bind(move |_, item| { - let Some(item) = item.downcast_ref::() else { + let Some(cell) = item.downcast_ref::() else { return; }; - let Some(row) = item.item().and_downcast::() else { + let (Some(row), Some(grid_cell)) = ( + cell.item().and_downcast::(), + cell.child().and_downcast::(), + ) else { return; }; + let lock = cell_lock(catalog.as_ref(), row.id(), &tab_ctx_for_bind); + grid_cell.set_lock(lock); let (value, pending_classes) = tab_ctx_for_bind.bind_state(&row, idx); - let is_null = matches!(value, Value::Null); - // An editable cell shows NULL as ``, so it reads differently - // from an empty string; a read-only one dims a plain NULL. - let text = if is_null && column_auto_filled { - auto_filled_sentinel() - } else if editable_for_bind { - if is_null { - editable_null_sentinel() - } else { - value_to_edit_text(&value) - } - } else { - value_to_display_text(&value) - }; - - let is_pending_delete = pending_classes.contains(&"tp-row-pending-delete"); - let Some(child) = item.child() else { return }; - if let Ok(label) = child.clone().downcast::() { - label.set_text(&text); - apply_cell_tooltip(label.upcast_ref(), &text, is_null); - if is_null && !editable_for_bind { - label.add_css_class("dim-label"); - } else { - label.remove_css_class("dim-label"); - } - if is_null && (editable_for_bind || column_auto_filled) { - label.add_css_class("tp-null-sentinel"); - } else { - label.remove_css_class("tp-null-sentinel"); - } - clear_pending_classes(label.upcast_ref()); - for cls in &pending_classes { - label.add_css_class(cls); - } - label.set_strikethrough(is_pending_delete); - POSITION_SLOT.set(&label, item.position()); - } else if let Ok(checkbox) = child.clone().downcast::() { - // Suppressed, so drawing the value does not echo back as an edit. - SUPPRESS_SLOT.set(&checkbox, true); - match value { - Value::Bool(true) => { - checkbox.set_inconsistent(false); - checkbox.set_active(true); - } - Value::Bool(false) => { - checkbox.set_inconsistent(false); - checkbox.set_active(false); - } - Value::Null => { - checkbox.set_inconsistent(true); - checkbox.set_active(false); - } - _ => { - // A value that is not a bool shows as inconsistent - // rather than as a confidently wrong check box. - checkbox.set_inconsistent(true); - checkbox.set_active(false); - } - } - SUPPRESS_SLOT.set(&checkbox, false); - clear_pending_classes(checkbox.upcast_ref()); - for cls in &pending_classes { - checkbox.add_css_class(cls); - } - // A check box has no strikethrough, so a pending delete dims it. - checkbox.set_opacity(if is_pending_delete { 0.5 } else { 1.0 }); - POSITION_SLOT.set(&checkbox, item.position()); - } else if let Ok(label) = child.downcast::() { - label.set_text(&text); - apply_cell_tooltip(label.upcast_ref(), &text, is_null); - if is_null { - label.add_css_class("dim-label"); - } else { - label.remove_css_class("dim-label"); - } - clear_pending_classes(label.upcast_ref()); - for cls in &pending_classes { - label.add_css_class(cls); - } - set_label_strikethrough(&label, is_pending_delete); - POSITION_SLOT.set(&label, item.position()); - } + draw_cell(&grid_cell, &value, &pending_classes, lock.is_none(), auto_filled); }); factory.connect_unbind(|_, item| { - let Some(item) = item.downcast_ref::() else { + let Some(grid_cell) = item + .downcast_ref::() + .and_then(|cell| cell.child()) + .and_downcast::() + else { return; }; - let Some(child) = item.child() else { return }; - if let Ok(label) = child.clone().downcast::() { - if label.is_editing() { - label.stop_editing(false); - } - // An open popover unparents before the cell is recycled, or - // GTK finalizes the cell with a GtkPopover child left in it. - if let Some(popover) = POPOVER_SLOT.take(&label) { - popover.popdown(); - } - POSITION_SLOT.take(&label); - SNAPSHOT_SLOT.take(&label); - } else if let Ok(checkbox) = child.clone().downcast::() { - POSITION_SLOT.take(&checkbox); - } else if let Ok(label) = child.downcast::() { - POSITION_SLOT.take(&label); + if let Some(editor) = grid_cell.editor() + && editor.is_editing() + { + editor.stop_editing(false); } + if let Some(popover) = grid_cell.take_popover() { + popover.popdown(); + } + grid_cell.take_edit(); }); let column = gtk::ColumnViewColumn::builder() @@ -322,7 +242,7 @@ fn build_column( .resizable(true) .expand(true) .build(); - if sort_sender.is_some() { + if sortable { let dummy = gtk::CustomSorter::new(|_, _| gtk::Ordering::Equal); column.set_sorter(Some(&dummy)); } @@ -343,97 +263,105 @@ fn build_column( column } -#[expect( - clippy::too_many_arguments, - reason = "each argument is captured by a different cell signal handler" -)] -fn setup_editable_cell( - item: >k::ListItem, - idx: usize, - column_name: String, - sender: relm4::Sender, - editor_kind: CellEditorKind, - accepts_empty: bool, - column_view: &glib::WeakRef, - menus: &GridMenus, -) { - let label = CellEditor::new(); - label.set_hexpand(true); - label.set_margin_start(8); - label.set_margin_end(8); - // Ctrl+Shift+N reads the focused cell's column from here. Its row - // changes as rows are recycled, so the bind writes POSITION_SLOT. - COLUMN_SLOT.set(&label, idx); - item.set_child(Some(&label)); - - attach_cell_gesture( - label.upcast_ref(), - column_view, - CellMenuTarget { - col_index: idx, - column_name, - editable: true, - text_editable: true, - accepts_empty, - }, - menus, - ); - install_edit_commit_handler(&label, idx, sender.clone()); - install_edit_triggers(&label, idx, sender, editor_kind); +fn cell_lock(catalog: Option<&ColumnInfo>, row: &GridRowId, tab_ctx: &TabGridContext) -> Option { + match catalog { + Some(column) => cell_editability(column, row, &tab_ctx.identity, !tab_ctx.editable).err(), + None => Some(CellLock::ResultRow), + } } -fn setup_bool_cell( - item: >k::ListItem, - idx: usize, - column_name: String, - sender: relm4::Sender, - column_view: &glib::WeakRef, - menus: &GridMenus, -) { +/// `editable` is whether this row's cell takes an edit: such a cell +/// shows NULL as ``, so it reads differently from an empty +/// string, and a locked one dims a plain NULL. +fn draw_cell(grid_cell: &GridCell, value: &Value, pending_classes: &[&'static str], editable: bool, auto_filled: bool) { + let is_null = matches!(value, Value::Null); + let text = if is_null && auto_filled { + auto_filled_sentinel() + } else if editable { + if is_null { + editable_null_sentinel() + } else { + value_to_edit_text(value) + } + } else { + value_to_display_text(value) + }; + let is_pending_delete = pending_classes.contains(&"tp-row-pending-delete"); + let Some(content) = grid_cell.content() else { + return; + }; + if let Some(editor) = content.downcast_ref::() { + editor.set_text(&text); + apply_cell_tooltip(content, &text, is_null); + set_class(content, "dim-label", is_null && !editable); + set_class(content, "tp-null-sentinel", is_null && (editable || auto_filled)); + apply_pending_classes(content, pending_classes); + editor.set_strikethrough(is_pending_delete); + } else if let Some(checkbox) = content.downcast_ref::() { + // Suppressed, so drawing the value does not echo back as an edit. + grid_cell.suppress_toggle(true); + // A value that is not a bool shows as inconsistent rather than as + // a confidently wrong check box. + let (active, inconsistent) = match value { + Value::Bool(flag) => (*flag, false), + _ => (false, true), + }; + checkbox.set_inconsistent(inconsistent); + checkbox.set_active(active); + grid_cell.suppress_toggle(false); + checkbox.set_sensitive(editable); + apply_pending_classes(content, pending_classes); + // A check box has no strikethrough, so a pending delete dims it. + checkbox.set_opacity(if is_pending_delete { 0.5 } else { 1.0 }); + } else if let Some(label) = content.downcast_ref::() { + label.set_text(&text); + apply_cell_tooltip(content, &text, is_null); + set_class(content, "dim-label", is_null); + apply_pending_classes(content, pending_classes); + set_label_strikethrough(label, is_pending_delete); + } +} + +fn setup_editable_cell(grid_cell: &GridCell, sink: GridSink, editor_kind: CellEditorKind) { + let editor = CellEditor::new(); + editor.set_hexpand(true); + editor.set_margin_start(8); + editor.set_margin_end(8); + grid_cell.set_content(&editor); + install_edit_commit_handler(grid_cell, sink.clone()); + install_edit_triggers(grid_cell, sink, editor_kind); +} + +fn setup_toggle_cell(grid_cell: &GridCell, sink: GridSink) { let checkbox = gtk::CheckButton::builder() .halign(gtk::Align::Start) .valign(gtk::Align::Center) .margin_start(8) .margin_end(8) .build(); - COLUMN_SLOT.set(&checkbox, idx); - item.set_child(Some(&checkbox)); - - attach_cell_gesture( - checkbox.upcast_ref(), - column_view, - CellMenuTarget { - col_index: idx, - column_name, - editable: true, - text_editable: false, - accepts_empty: false, - }, - menus, - ); - checkbox.connect_toggled(move |cb| { - if SUPPRESS_SLOT.get(cb).unwrap_or(false) { + grid_cell.set_content(&checkbox); + // The cell owns the check button, so the handler finds the cell + // through its parent rather than holding it. + checkbox.connect_toggled(move |checkbox| { + let Some(grid_cell) = checkbox.parent().and_downcast::() else { + return; + }; + if grid_cell.toggle_suppressed() || grid_cell.lock().is_some() { return; } - let position = POSITION_SLOT.get(cb).unwrap_or(0); - let new_value = if cb.is_active() { "true" } else { "false" }; - sender - .send(GridMsg::CellEdited { - row_position: position, - col_index: idx, - new_value: new_value.to_string(), - }) - .ok(); + let Some(row) = grid_cell.row() else { + return; + }; + let input = if checkbox.is_active() { "true" } else { "false" }; + sink(GridMsg::CellEdited { + row, + column: grid_cell.column(), + input: input.to_owned(), + }); }); } -fn setup_readonly_cell( - item: >k::ListItem, - idx: usize, - column_name: String, - column_view: &glib::WeakRef, - menus: &GridMenus, -) { +fn setup_readonly_cell(grid_cell: &GridCell) { let label = gtk::Label::builder() .xalign(0.0) .hexpand(true) @@ -442,25 +370,24 @@ fn setup_readonly_cell( .margin_start(8) .margin_end(8) .build(); - item.set_child(Some(&label)); - attach_cell_gesture( - label.upcast_ref(), - column_view, - CellMenuTarget { - col_index: idx, - column_name, - editable: false, - text_editable: false, - accepts_empty: false, - }, - menus, - ); + grid_cell.set_content(&label); } -fn clear_pending_classes(widget: >k::Widget) { +fn set_class(widget: >k::Widget, class: &str, on: bool) { + if on { + widget.add_css_class(class); + } else { + widget.remove_css_class(class); + } +} + +fn apply_pending_classes(widget: >k::Widget, pending_classes: &[&'static str]) { for cls in PENDING_CSS_CLASSES { widget.remove_css_class(cls); } + for cls in pending_classes { + widget.add_css_class(cls); + } } fn apply_cell_tooltip(widget: >k::Widget, text: &str, is_null: bool) { @@ -483,28 +410,31 @@ fn set_label_strikethrough(label: >k::Label, on: bool) { } } -fn is_cell_editable(col: &ColumnInfo) -> bool { - // A primary key is the row's identity in the tracker, and the server - // rejects a value for a generated or auto-increment column. - !col.primary_key && !col.is_generated && !col.is_auto_increment && !is_bytes_kind(&col.column_type.kind) -} - -/// A blob has no text form to type into, so the grid never offers one. -fn is_bytes_kind(kind: &tablepro_core::column::ColumnKind) -> bool { - matches!(kind, tablepro_core::column::ColumnKind::Bytes { .. }) -} - #[cfg(test)] mod tests { + use std::cell::RefCell; + use std::rc::Rc; + use std::time::Duration; + use gtk4::prelude::*; use gtk4::{self as gtk, gio, glib}; + use tablepro_core::meta::RowIdentity; use tablepro_core::{ColumnInfo, QueryResult, Value}; - use super::{build_column_view, is_cell_editable}; + use super::build_column_view; use crate::services::change_tracker::RowKey; use crate::services::tab_tracker_handle::TabTrackerHandle; - use crate::ui::grid::{CellEditor, ColumnWidthBinding, GridMsg, RowObject, TabGridContext}; + use crate::services::tracker_delta::VisualChange; + use crate::services::tracker_edit::TrackerEdit; + use crate::test_support::{descendants, drain_main_context, wait_until}; + use crate::ui::grid::cell_editability::column_takes_edits; + use crate::ui::grid::{ + CellEditor, CellPreset, ColumnWidthBinding, GridCell, GridMsg, GridSink, RowObject, TabGridContext, + enter_edit_mode, + }; + + const WAIT: Duration = Duration::from_secs(5); #[expect( clippy::disallowed_methods, @@ -533,65 +463,302 @@ mod tests { } } - fn cell_texts(root: &impl IsA) -> Vec { - crate::test_support::descendants(root) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .map(|cell| cell.text().to_string()) + fn named(name: &str, data_type: &str, primary_key: bool) -> ColumnInfo { + let mut column = col(data_type, primary_key); + column.name = name.to_owned(); + column + } + + fn key(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") + } + + fn keyed_by_id() -> RowIdentity { + RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + } + } + + fn tracked(tracker: &TabTrackerHandle) -> TabGridContext { + TabGridContext { + tracker: Some(tracker.clone()), + editable: true, + identity: keyed_by_id(), + column_widths: None, + } + } + + /// Persisted rows keyed by their first cell, as a browse page is. + fn persisted(rows: &[Vec]) -> Vec { + rows.iter() + .map(|cells| { + let id = cells.first().cloned().unwrap_or(Value::Null); + RowObject::persisted(RowKey::from_pk_values(&[id]), cells.clone().into()) + }) .collect() } + fn recorder() -> (GridSink, Rc>>) { + let messages = Rc::new(RefCell::new(Vec::new())); + let log = messages.clone(); + (Rc::new(move |msg| log.borrow_mut().push(msg)), messages) + } + + struct Grid { + window: gtk::Window, + view: gtk::ColumnView, + store: gio::ListStore, + tracker: TabTrackerHandle, + messages: Rc>>, + } + + impl Grid { + /// Two rows, A (id 1) and B (id 2), in a grid that edits them. + fn open(columns: Vec, rows: Vec>) -> Self { + let result = QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + rows.clone(), + ); + let tracker = TabTrackerHandle::default(); + let (sink, messages) = recorder(); + let (view, selection) = build_column_view( + &result, + persisted(&rows), + &columns, + sink, + None, + false, + tracked(&tracker), + ); + let store = selection + .model() + .and_downcast::() + .expect("a list store"); + let window = gtk::Window::new(); + window.set_default_size(600, 400); + window.set_child(Some(&view)); + window.present(); + let grid = Self { + window, + view, + store, + tracker, + messages, + }; + wait_until(WAIT, || grid.cells().len() == columns.len() * rows.len()).expect("the cells are drawn"); + grid + } + + fn cells(&self) -> Vec { + descendants(&self.view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|cell| cell.row().is_some()) + .collect() + } + + /// The cell that shows `column` of the row named `id`. + fn cell_of(&self, id: i64, column: usize) -> GridCell { + self.cells() + .into_iter() + .find(|cell| cell.column() == column && cell.row().is_some_and(|row| row.key() == Some(&key(id)))) + .expect("the row's cell is drawn") + } + + fn insert_draft(&self) { + let delta = self.tracker.apply(TrackerEdit::Insert { + values: vec![Value::Null, Value::Null], + }); + let Some(VisualChange::InsertDraft { draft_id }) = delta.visual.into_iter().next() else { + panic!("an insert adds a draft row"); + }; + self.store.insert(0, &RowObject::draft(draft_id)); + drain_main_context(); + } + + fn column(&self, index: u32) -> gtk::ColumnViewColumn { + self.view + .columns() + .item(index) + .and_downcast::() + .expect("a column") + } + + fn take_messages(&self) -> Vec { + self.messages.take() + } + } + + impl Drop for Grid { + fn drop(&mut self) { + self.window.destroy(); + drain_main_context(); + } + } + + fn two_text_rows() -> Grid { + Grid::open( + vec![named("id", "integer", true), named("name", "text", false)], + vec![ + vec![Value::Int(1), Value::Text("A".into())], + vec![Value::Int(2), Value::Text("B".into())], + ], + ) + } + + fn type_into(cell: &GridCell, text: &str) -> CellEditor { + enter_edit_mode(cell); + let editor = cell.editor().expect("a text cell"); + assert!(editor.is_editing(), "the cell did not open for editing"); + editor.entry().set_text(text); + editor + } + + fn edited_rows(messages: &[GridMsg]) -> Vec<(Option, usize, String)> { + messages + .iter() + .filter_map(|msg| match msg { + GridMsg::CellEdited { row, column, input } => Some((row.key().cloned(), *column, input.clone())), + _ => None, + }) + .collect() + } + + /// Without focusable cells, which come with the grid's keyboard + /// model, a grid cell takes the focus through its open editor. #[gtk4::test] - fn edit_during_rebind_shows_pending_value() { - use crate::services::tracker_edit::TrackerEdit; + fn edit_after_draft_insert_targets_edited_row() { + let grid = two_text_rows(); + grid.insert_draft(); + grid.view + .scroll_to(2, Some(&grid.column(1)), gtk::ListScrollFlags::FOCUS, None); + + let editor = type_into(&grid.cell_of(2, 1), "renamed"); + let mut focused = None; + wait_until(WAIT, || { + focused = gtk::prelude::GtkWindowExt::focus(&grid.window).and_then(|focus| GridCell::from_focus(&focus)); + focused.is_some() + }) + .expect("the focused editor resolves to its grid cell"); + let cell = focused.expect("a focused cell"); + assert_eq!(cell.row().and_then(|row| row.key().cloned()), Some(key(2))); + editor.stop_editing(true); + + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(2)), 1, "renamed".to_owned())] + ); + } - let mut id = col("integer", true); - id.name = "id".into(); - let mut name = col("text", false); - name.name = "name".into(); - let columns = vec![id, name]; - let result = QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - vec![vec![Value::Int(1), Value::Text("old".into())]], + #[gtk4::test] + fn menu_set_null_after_draft_removal_targets_clicked_row() { + let grid = two_text_rows(); + grid.insert_draft(); + let cell = grid.cell_of(2, 1); + let gesture = menu_gesture(cell.upcast_ref()).expect("a cell answers a right-click"); + gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + + grid.store.remove(0); + drain_main_context(); + grid.view + .activate_action("cell.set-null", None) + .expect("the menu's Set NULL"); + + let set: Vec<(Option, usize, CellPreset)> = grid + .take_messages() + .iter() + .filter_map(|msg| match msg { + GridMsg::SetCellValue { row, column, preset } => Some((row.key().cloned(), *column, *preset)), + _ => None, + }) + .collect(); + assert_eq!(set, vec![(Some(key(2)), 1, CellPreset::Null)]); + } + + #[gtk4::test] + fn edit_started_before_rows_shift_commits_to_original_row() { + let grid = two_text_rows(); + let cell = grid.cell_of(2, 1); + let editor = type_into(&cell, "renamed"); + + grid.insert_draft(); + editor.stop_editing(true); + + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(2)), 1, "renamed".to_owned())] ); - let tracker = TabTrackerHandle::default(); - let (sender, _receiver) = relm4::channel::(); - let context = TabGridContext { - tracker: Some(tracker.clone()), - pk_col_indices: vec![0], - column_widths: None, + } + + #[gtk4::test] + fn toggle_after_draft_insert_targets_toggled_row() { + let grid = Grid::open( + vec![named("id", "integer", true), named("flag", "boolean", false)], + vec![ + vec![Value::Int(1), Value::Bool(true)], + vec![Value::Int(2), Value::Bool(false)], + ], + ); + grid.insert_draft(); + let toggle = grid + .cell_of(2, 1) + .content() + .and_then(|content| content.downcast_ref::().cloned()) + .expect("a check button"); + + toggle.set_active(true); + + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(2)), 1, "true".to_owned())] + ); + } + + #[gtk4::test] + fn a_saved_rows_key_is_locked_and_a_drafts_is_not() { + let grid = two_text_rows(); + grid.insert_draft(); + let draft_key_cell = grid + .cells() + .into_iter() + .find(|cell| cell.column() == 0 && cell.row().is_some_and(|row| row.draft_id().is_some())) + .expect("the draft's key cell"); + + assert!( + grid.cell_of(1, 0).lock().is_some(), + "a saved row's key was offered for editing" + ); + assert!(grid.cell_of(1, 1).lock().is_none()); + assert!(draft_key_cell.lock().is_none(), "a draft could not set its own key"); + + enter_edit_mode(&grid.cell_of(1, 0)); + assert!(!grid.cell_of(1, 0).editor().expect("a text cell").is_editing()); + } + + #[gtk4::test] + fn edit_during_rebind_shows_pending_value() { + let grid = two_text_rows(); + let texts = || -> Vec { + descendants(&grid.view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .map(|cell| cell.text().to_string()) + .collect() }; - let (view, selection) = build_column_view(&result, &columns, sender, true, None, None, context); - let window = gtk::Window::new(); - window.set_child(Some(&view)); - window.present(); - crate::test_support::wait_until(std::time::Duration::from_secs(5), || { - cell_texts(&view).iter().any(|text| text == "old") - }) - .expect("the fetched value is drawn"); - let delta = tracker.apply(TrackerEdit::Cell { - row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + let delta = grid.tracker.apply(TrackerEdit::Cell { + row_key: key(1), col: 1, - original: Value::Text("old".into()), + original: Value::Text("A".into()), value: Value::Text("new".into()), }); // A fresh row object, because the list item manager skips the // rebind for an items-changed that keeps the same item. - let store = selection - .model() - .and_downcast::() - .expect("a list store"); - let row = store.item(0).and_downcast::().expect("a row"); - store.splice(0, 1, &[RowObject::new(row.cells_clone())]); - - crate::test_support::wait_until(std::time::Duration::from_secs(5), || { - cell_texts(&view).iter().any(|text| text == "new") - }) - .expect("the rebind draws the pending value"); + let row = grid.store.item(0).and_downcast::().expect("a row"); + grid.store.splice(0, 1, &[row.replacement()]); + + wait_until(WAIT, || texts().iter().any(|text| text == "new")).expect("the rebind draws the pending value"); assert_eq!(delta.pending_count, 1); - window.close(); - crate::test_support::drain_main_context(); } fn width_binding(tasks: &tablepro_session::runtime::Tasks, root: &tempfile::TempDir) -> ColumnWidthBinding { @@ -607,26 +774,25 @@ mod tests { fn column_freed_with_column_view() { let runtime = crate::test_support::test_runtime(); let root = tempfile::tempdir().expect("tempdir"); - let mut name = col("text", false); - name.name = "name".into(); - let columns = vec![name]; + let columns = vec![named("name", "text", false)]; + let rows = vec![vec![Value::Text("a".into())]]; let result = QueryResult::new( columns.iter().map(ColumnInfo::result_column).collect::>(), - vec![vec![Value::Text("a".into())]], + rows.clone(), ); - let (sender, _receiver) = relm4::channel::(); + let (sink, _messages) = recorder(); let context = TabGridContext { - tracker: None, - pk_col_indices: Vec::new(), + editable: true, column_widths: Some(width_binding(&runtime.tasks(), &root)), + ..TabGridContext::default() }; let (view, selection) = build_column_view( &result, + persisted(&rows), &columns, - sender.clone(), - true, + sink, Some((0, true)), - Some(sender), + true, context, ); let column = view @@ -638,7 +804,7 @@ mod tests { let view_ref = view.downgrade(); drop((view, selection)); - crate::test_support::drain_main_context(); + drain_main_context(); assert!(column.upgrade().is_none(), "the column outlived its column view"); assert!(view_ref.upgrade().is_none(), "the column view outlived its owner"); @@ -653,92 +819,55 @@ mod tests { #[gtk4::test] fn cells_freed_with_column_view() { - let mut id = col("integer", true); - id.name = "id".into(); - let mut name = col("text", false); - name.name = "name".into(); - let mut flag = col("boolean", false); - flag.name = "flag".into(); - let columns = vec![id, name, flag]; + let columns = vec![ + named("id", "integer", true), + named("name", "text", false), + named("flag", "boolean", false), + ]; + let rows = vec![vec![Value::Int(7), Value::Text("a".into()), Value::Bool(true)]]; let result = QueryResult::new( columns.iter().map(ColumnInfo::result_column).collect::>(), - vec![vec![Value::Int(7), Value::Text("a".into()), Value::Bool(true)]], + rows.clone(), + ); + let tracker = TabTrackerHandle::default(); + let (sink, _messages) = recorder(); + let (view, selection) = build_column_view( + &result, + persisted(&rows), + &columns, + sink, + None, + false, + tracked(&tracker), ); - let (sender, _receiver) = relm4::channel::(); - let context = TabGridContext { - tracker: Some(TabTrackerHandle::default()), - pk_col_indices: vec![0], - column_widths: None, - }; - let (view, selection) = build_column_view(&result, &columns, sender, true, None, None, context); let window = gtk::Window::new(); window.set_child(Some(&view)); window.present(); - let cells = || -> Vec { - crate::test_support::descendants(&view) + let cells = || -> Vec { + descendants(&view) .into_iter() - .filter(|widget| { - widget.is::() - || widget.is::() - || widget - .downcast_ref::() - .is_some_and(|label| label.text() == "7") - }) + .filter_map(|widget| widget.downcast::().ok()) + .filter(|cell| cell.row().is_some()) .collect() }; - crate::test_support::wait_until(std::time::Duration::from_secs(5), || cells().len() == 3) - .expect("the read-only, text and bool cells are drawn"); + wait_until(WAIT, || cells().len() == 3).expect("the key, text and bool cells are drawn"); // A right-click is what fills the menu's target, so it has to be // in the picture too. for cell in cells() { - let gesture = menu_gesture(&cell).expect("a cell answers a right-click"); + let gesture = menu_gesture(cell.upcast_ref()).expect("a cell answers a right-click"); gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); } - let freed: Vec> = cells().iter().map(ObjectExt::downgrade).collect(); + let freed: Vec> = cells().iter().map(ObjectExt::downgrade).collect(); window.destroy(); drop(window); drop((view, selection)); - crate::test_support::drain_main_context(); + drain_main_context(); let alive = freed.iter().filter(|cell| cell.upgrade().is_some()).count(); assert_eq!(alive, 0, "cells outlived their column view"); } - #[test] - fn editable_for_normal_column() { - assert!(is_cell_editable(&col("text", false))); - assert!(is_cell_editable(&col("integer", false))); - } - - #[test] - fn not_editable_for_primary_key() { - assert!(!is_cell_editable(&col("integer", true))); - } - - #[test] - fn not_editable_for_generated_column() { - let mut c = col("integer", false); - c.is_generated = true; - assert!(!is_cell_editable(&c)); - } - - #[test] - fn not_editable_for_auto_increment_non_pk() { - let mut c = col("integer", false); - c.is_auto_increment = true; - assert!(!is_cell_editable(&c)); - } - - #[test] - fn not_editable_for_bytes() { - assert!(!is_cell_editable(&col("bytea", false))); - assert!(!is_cell_editable(&col("blob", false))); - assert!(!is_cell_editable(&col("longblob", false))); - assert!(!is_cell_editable(&col("BINARY", false))); - assert!(!is_cell_editable(&col("varbinary", false))); - } - #[test] fn only_columns_with_an_empty_value_accept_an_empty_string() { // Clearing a text cell stores the empty string; clearing a @@ -767,6 +896,7 @@ mod tests { #[test] fn a_blob_is_never_editable_whatever_the_engine_calls_it() { + let identity = keyed_by_id(); for name in [ "BYTEA", "blob", @@ -777,11 +907,11 @@ mod tests { "binary", ] { assert!( - !is_cell_editable(&col(name, false)), + !column_takes_edits(&col(name, false), &identity, false), "{name} was offered as an editable cell" ); } - assert!(is_cell_editable(&col("text", false))); - assert!(is_cell_editable(&col("integer", false))); + assert!(column_takes_edits(&col("text", false), &identity, false)); + assert!(column_takes_edits(&col("integer", false), &identity, false)); } } diff --git a/linux/crates/app/src/ui/grid/context_menu.rs b/linux/crates/app/src/ui/grid/context_menu.rs index 043ac0aecc..c12b256f10 100644 --- a/linux/crates/app/src/ui/grid/context_menu.rs +++ b/linux/crates/app/src/ui/grid/context_menu.rs @@ -1,86 +1,70 @@ use gtk4::prelude::*; use gtk4::{self as gtk, glib}; +use super::GridCell; use super::grid_menus::GridMenus; use super::selection::select_row_for_menu; -use super::widget_slot::POSITION_SLOT; -#[derive(Clone)] +/// The cell a menu was opened on. The cell holds the menu through its +/// gesture, so the menu holds the cell weakly, and asks it for its row +/// only when an item is chosen. +#[derive(Default)] pub(super) struct CellContext { - pub(super) widget: gtk::Widget, - pub(super) col_index: usize, - pub(super) column_name: String, + cell: glib::WeakRef, } -/// The cell holds the menu through its gesture, so the menu holds the -/// cell weakly. -pub(super) struct ClickedCell { - widget: glib::WeakRef, - col_index: usize, - column_name: String, -} +impl CellContext { + fn set(&self, cell: &GridCell) { + self.cell.set(Some(cell)); + } -impl ClickedCell { - pub(super) fn get(&self) -> Option { - Some(CellContext { - widget: self.widget.upgrade()?, - col_index: self.col_index, - column_name: self.column_name.clone(), - }) + pub(super) fn cell(&self) -> Option { + self.cell.upgrade() } } -#[derive(Clone)] +/// What a column's cells offer in the menu while their row is unlocked. +#[derive(Clone, Copy)] pub(super) struct CellMenuTarget { - pub(super) col_index: usize, - pub(super) column_name: String, - pub(super) editable: bool, pub(super) text_editable: bool, pub(super) accepts_empty: bool, } pub(super) fn attach_cell_gesture( - widget: >k::Widget, + cell: &GridCell, column_view: &glib::WeakRef, target: CellMenuTarget, menus: &GridMenus, ) { - let popover = if target.editable { - menus.editable_popover.clone() - } else { - menus.readonly_popover.clone() - }; - + // The cell's lock follows the row bound to it, so the menu is picked + // when it opens. let prepare = { - let context = menus.context.clone(); - let actions = menus.actions.clone(); - let target = target.clone(); - move |widget: >k::Widget, column_view: >k::ColumnView| { - *context.borrow_mut() = Some(ClickedCell { - widget: widget.downgrade(), - col_index: target.col_index, - column_name: target.column_name.clone(), - }); - actions.arm(&target); - if let Some(position) = POSITION_SLOT.get(widget) { + let menus = menus.clone(); + move |cell: &GridCell, column_view: >k::ColumnView| -> gtk::PopoverMenu { + menus.context.set(cell); + menus.actions.arm(&target); + if let Some(position) = cell.position() { select_row_for_menu(column_view, position); } + match cell.lock() { + None => menus.editable_popover.clone(), + Some(_) => menus.readonly_popover.clone(), + } } }; // The handlers below read the cell from their own arguments: the // cell owns its controllers, so capturing it would be a cycle. let view_for_gesture = column_view.clone(); - let popover_for_gesture = popover.clone(); let prepare_for_gesture = prepare.clone(); let gesture = gtk::GestureClick::new(); gesture.set_button(3); gesture.connect_pressed(move |g, _, x, y| { - let (Some(cv), Some(cell)) = (view_for_gesture.upgrade(), g.widget()) else { + let (Some(cv), Some(cell)) = (view_for_gesture.upgrade(), g.widget().and_downcast::()) else { return; }; g.set_state(gtk::EventSequenceState::Claimed); - prepare_for_gesture(&cell, &cv); + let popover = prepare_for_gesture(&cell, &cv); // The popover is parented to the column view, so it points in // the view's coordinates, not the cell's. let local = gtk::graphene::Point::new(x as f32, y as f32); @@ -88,23 +72,22 @@ pub(super) fn attach_cell_gesture( .compute_point(&cv, &local) .map(|p| (p.x() as i32, p.y() as i32)) .unwrap_or((x as i32, y as i32)); - popover_for_gesture.set_pointing_to(Some(>k::gdk::Rectangle::new(cv_x, cv_y, 1, 1))); - popover_for_gesture.popup(); + popover.set_pointing_to(Some(>k::gdk::Rectangle::new(cv_x, cv_y, 1, 1))); + popover.popup(); }); - widget.add_controller(gesture); + cell.add_controller(gesture); let view_for_key = column_view.clone(); - let popover_for_key = popover; let menu_shortcut = gtk::Shortcut::builder() .trigger(>k::KeyvalTrigger::new( gtk::gdk::Key::Menu, gtk::gdk::ModifierType::empty(), )) - .action(>k::CallbackAction::new(move |cell, _| { - let Some(cv) = view_for_key.upgrade() else { + .action(>k::CallbackAction::new(move |widget, _| { + let (Some(cv), Some(cell)) = (view_for_key.upgrade(), widget.downcast_ref::()) else { return glib::Propagation::Proceed; }; - prepare(cell, &cv); + let popover = prepare(cell, &cv); // The Menu key has no pointer position, so the popover points // at the whole cell. if let Some(bounds) = cell.compute_bounds(&cv) { @@ -114,15 +97,15 @@ pub(super) fn attach_cell_gesture( bounds.width() as i32, bounds.height() as i32, ); - popover_for_key.set_pointing_to(Some(&rect)); + popover.set_pointing_to(Some(&rect)); } else { - popover_for_key.set_pointing_to(None); + popover.set_pointing_to(None); } - popover_for_key.popup(); + popover.popup(); glib::Propagation::Stop })) .build(); let shortcut_controller = gtk::ShortcutController::new(); shortcut_controller.add_shortcut(menu_shortcut); - widget.add_controller(shortcut_controller); + cell.add_controller(shortcut_controller); } diff --git a/linux/crates/app/src/ui/grid/edit_snapshot.rs b/linux/crates/app/src/ui/grid/edit_snapshot.rs new file mode 100644 index 0000000000..85fcb26b51 --- /dev/null +++ b/linux/crates/app/src/ui/grid/edit_snapshot.rs @@ -0,0 +1,10 @@ +use super::RowObject; + +/// An edit as it stood when it began. The commit goes to `row`, not to +/// whatever row the cell shows by then. +#[derive(Debug, Clone)] +pub struct EditSnapshot { + pub row: RowObject, + pub column: usize, + pub original_text: String, +} diff --git a/linux/crates/app/src/ui/grid/grid_cell.rs b/linux/crates/app/src/ui/grid/grid_cell.rs new file mode 100644 index 0000000000..74912cd8ac --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_cell.rs @@ -0,0 +1,210 @@ +use std::cell::{Cell, OnceCell, RefCell}; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, glib}; + +use super::{CellEditor, CellLock, EditSnapshot, RowObject}; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct GridCell { + pub list_item: glib::WeakRef, + pub column: Cell, + pub content: OnceCell, + /// An open editor popover. Unbind pops it down, or the recycled + /// cell is finalized with a GtkPopover child still in it. + pub popover: RefCell>, + pub edit: RefCell>, + /// Set while the bind draws a check button, which would otherwise + /// echo back as an edit of the value it just drew. + pub suppress_toggle: Cell, + /// Set while an IME composition is in flight: focus moving to the + /// IME popover ends the edit, and committing then would send the + /// text from before the composition. + pub preedit_active: Cell, + /// Why the row bound now cannot be edited here. + pub lock: Cell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for GridCell { + const NAME: &'static str = "TableProGridCell"; + type Type = super::GridCell; + type ParentType = gtk::Widget; + + fn class_init(klass: &mut Self::Class) { + klass.set_layout_manager_type::(); + klass.set_css_name("tp-grid-cell"); + } + } + + impl ObjectImpl for GridCell { + fn dispose(&self) { + if let Some(popover) = self.popover.take() { + popover.unparent(); + } + if let Some(content) = self.content.get() { + content.unparent(); + } + } + } + + impl WidgetImpl for GridCell {} +} + +glib::wrapper! { + /// One cell of the grid. It resolves its row through the column view + /// cell when an event fires, so the row is the one the cell shows + /// then, not the one it was bound to. + pub struct GridCell(ObjectSubclass) + @extends gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl Default for GridCell { + fn default() -> Self { + Self::new() + } +} + +impl GridCell { + pub fn new() -> Self { + glib::Object::new() + } + + pub fn attach(&self, cell: >k::ColumnViewCell, column: usize) { + self.imp().list_item.set(Some(cell)); + self.imp().column.set(column); + } + + /// The widget that draws the value. Set once, at setup. + pub fn set_content(&self, content: &impl IsA) { + let content = content.as_ref(); + if self.imp().content.set(content.clone()).is_ok() { + content.set_parent(self); + } + } + + pub fn content(&self) -> Option<>k::Widget> { + self.imp().content.get() + } + + pub fn editor(&self) -> Option { + self.content()?.clone().downcast().ok() + } + + pub fn row(&self) -> Option { + self.imp().list_item.upgrade()?.item().and_downcast() + } + + pub fn position(&self) -> Option { + let position = self.imp().list_item.upgrade()?.position(); + (position != gtk::INVALID_LIST_POSITION).then_some(position) + } + + pub fn column(&self) -> usize { + self.imp().column.get() + } + + pub fn is_editing(&self) -> bool { + self.imp().edit.borrow().is_some() || self.imp().popover.borrow().is_some() + } + + /// The cell that holds the keyboard focus. A focused column view + /// cell widget is this cell's parent, so its first child is tried + /// after its ancestors. + pub fn from_focus(focus: >k::Widget) -> Option { + if let Some(cell) = focus.ancestor(GridCell::static_type()) { + return cell.downcast().ok(); + } + focus.first_child()?.downcast().ok() + } + + pub fn lock(&self) -> Option { + self.imp().lock.get() + } + + pub(super) fn set_lock(&self, lock: Option) { + self.imp().lock.set(lock); + } + + pub(super) fn begin_edit(&self, snapshot: EditSnapshot) { + self.imp().edit.replace(Some(snapshot)); + } + + pub(super) fn take_edit(&self) -> Option { + self.imp().edit.take() + } + + pub(super) fn set_popover(&self, popover: >k::Popover) { + if let Some(previous) = self.imp().popover.replace(Some(popover.clone())) { + previous.popdown(); + } + } + + pub(super) fn take_popover(&self) -> Option { + self.imp().popover.take() + } + + /// Forget `popover` if it is still this cell's, leaving a newer one + /// in place. + pub(super) fn release_popover(&self, popover: >k::Popover) { + let mut slot = self.imp().popover.borrow_mut(); + if slot.as_ref() == Some(popover) { + slot.take(); + } + } + + pub(super) fn toggle_suppressed(&self) -> bool { + self.imp().suppress_toggle.get() + } + + pub(super) fn suppress_toggle(&self, suppressed: bool) { + self.imp().suppress_toggle.set(suppressed); + } + + pub(super) fn preedit_active(&self) -> bool { + self.imp().preedit_active.get() + } + + pub(super) fn set_preedit_active(&self, active: bool) { + self.imp().preedit_active.set(active); + } +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use gtk4::prelude::*; + + use super::GridCell; + use crate::ui::grid::CellEditor; + + #[gtk4::test] + fn from_focus_finds_the_cell_below_a_focused_cell_widget_or_above_its_editor() { + let cell_widget = gtk::Box::new(gtk::Orientation::Horizontal, 0); + let cell = GridCell::new(); + let editor = CellEditor::new(); + cell.set_content(&editor); + cell_widget.append(&cell); + + let below = GridCell::from_focus(cell_widget.upcast_ref()); + let above = GridCell::from_focus(editor.entry().upcast_ref()); + + assert_eq!(below.as_ref(), Some(&cell)); + assert_eq!(above.as_ref(), Some(&cell)); + assert!(GridCell::from_focus(gtk::Label::new(None).upcast_ref()).is_none()); + } + + #[gtk4::test] + fn an_unattached_cell_has_no_row() { + let cell = GridCell::new(); + + assert!(cell.row().is_none()); + assert!(cell.position().is_none()); + assert!(!cell.is_editing()); + } +} diff --git a/linux/crates/app/src/ui/grid/grid_menus.rs b/linux/crates/app/src/ui/grid/grid_menus.rs index 0377d67d5f..5a8643c219 100644 --- a/linux/crates/app/src/ui/grid/grid_menus.rs +++ b/linux/crates/app/src/ui/grid/grid_menus.rs @@ -1,4 +1,3 @@ -use std::cell::RefCell; use std::rc::Rc; use gtk4::prelude::*; @@ -6,13 +5,11 @@ use gtk4::{self as gtk, gio}; use tablepro_core::column::ResultColumn; -use super::cell_editor::CellEditor; -use super::context_menu::{CellContext, CellMenuTarget, ClickedCell}; +use super::context_menu::{CellContext, CellMenuTarget}; use super::inline_edit::enter_edit_mode; use super::row_json_dialog::show_row_json_dialog; -use super::selection::{export_snapshot, row_at, rows_for_menu}; -use super::widget_slot::POSITION_SLOT; -use super::{CellPreset, GridMsg, TabGridContext}; +use super::selection::{export_snapshot, rows_for_menu}; +use super::{CellPreset, GridMsg, GridSink, TabGridContext}; /// Enabled per right-clicked cell. Their items are hidden while /// disabled, so a cell they do not apply to shows no dead entry. `None` @@ -36,7 +33,7 @@ impl CellActions { #[derive(Clone)] pub(super) struct GridMenus { - pub(super) context: Rc>>, + pub(super) context: Rc, pub(super) editable_popover: gtk::PopoverMenu, pub(super) readonly_popover: gtk::PopoverMenu, pub(super) actions: CellActions, @@ -139,25 +136,24 @@ fn build_cell_menu(shape: MenuShape) -> gio::Menu { pub(super) struct GridMenuInit<'a> { pub(super) column_view: &'a gtk::ColumnView, - pub(super) sender: relm4::Sender, + pub(super) sink: GridSink, pub(super) columns: std::sync::Arc<[ResultColumn]>, /// From the fetch that filled the grid, so Export Results reports the /// same truncation the paginator does. pub(super) truncated: bool, pub(super) tab_ctx: TabGridContext, - pub(super) editable: bool, } pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { let GridMenuInit { column_view, - sender, + sink, columns, truncated, tab_ctx, - editable, } = init; - let context: Rc>> = Rc::new(RefCell::new(None)); + let editable = tab_ctx.editable; + let context = Rc::new(CellContext::default()); // Every action closure holds the view weakly. The action group // belongs to the ColumnView, so a strong clone in a closure is a // cycle: the grid, its selection model, its store and every row it @@ -166,17 +162,18 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { let tab = Rc::new(tab_ctx); let group = gio::SimpleActionGroup::new(); - let slot_position = |slot: &CellContext| POSITION_SLOT.get(&slot.widget).unwrap_or(0); + // The row is the one the cell shows when the item is chosen, which + // is still the clicked one after rows above it came or went. macro_rules! cell_action { - ($name:literal, |$slot:ident| $body:expr) => {{ + ($name:literal, |$cell:ident, $row:ident| $body:expr) => {{ let ctx = context.clone(); gio::ActionEntry::builder($name) .activate(move |_, _, _| { - let clicked = ctx.borrow().as_ref().and_then(ClickedCell::get); - if let Some($slot) = clicked.as_ref() { - $body; - } + let Some($cell) = ctx.cell() else { return }; + let Some($row) = $cell.row() else { return }; + let $cell = &$cell; + $body; }) .build() }}; @@ -191,92 +188,87 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { } } macro_rules! copy_action { - ($name:literal, |$slot:ident, $rows:ident| $message:expr) => {{ - let s = sender.clone(); + ($name:literal, |$rows:ident| $message:expr) => {{ + let s = sink.clone(); let view = view.clone(); let tab = tab.clone(); - cell_action!($name, |$slot| { + cell_action!($name, |_cell, row| { let Some(cv) = view.upgrade() else { return }; - let $rows = rows_for_menu(&cv, &tab, slot_position($slot)); - s.send($message).ok(); + let $rows = rows_for_menu(&cv, &tab, &row); + s($message); }) }}; } macro_rules! send_action { - ($name:literal, |$slot:ident| $msg:expr) => {{ - let s = sender.clone(); - cell_action!($name, |$slot| s.send($msg).ok()) + ($name:literal, |$cell:ident, $row:ident| $msg:expr) => {{ + let s = sink.clone(); + cell_action!($name, |$cell, $row| s($msg)) }}; } - let edit_action = cell_action!("edit", |slot| { - if let Ok(label) = slot.widget.clone().downcast::() { - enter_edit_mode(&label); - } - }); + let edit_action = cell_action!("edit", |cell, _row| enter_edit_mode(cell)); // Plain Copy reads the cell's value, never the widget's text: the // widget carries the display form, which is the `` sentinel, // the `(auto)` placeholder, `` for a blob and a value cut // at the 10k display cap. let copy_action = { - let s = sender.clone(); + let s = sink.clone(); let view = view.clone(); let tab = tab.clone(); let cols = columns.clone(); - cell_action!("copy", |slot| { + cell_action!("copy", |cell, row| { let Some(cv) = view.upgrade() else { return }; - let rows = rows_for_menu(&cv, &tab, slot_position(slot)); + let rows = rows_for_menu(&cv, &tab, &row); + let column = cell.column(); let message = match rows.as_slice() { [row] => GridMsg::CopyToClipboard( - row.get(slot.col_index) - .and_then(|value| tablepro_core::export::cell_text(&cols, slot.col_index, value)) + row.get(column) + .and_then(|value| tablepro_core::export::cell_text(&cols, column, value)) .unwrap_or_default(), ), many => clipboard_message(tablepro_core::export::render_tsv(&cols, many, false)), }; - s.send(message).ok(); + s(message); }) }; let copy_rows_action = { let cols = columns.clone(); - copy_action!("copy-rows", |_slot, rows| clipboard_message( + copy_action!("copy-rows", |rows| clipboard_message( tablepro_core::export::render_tsv(&cols, &rows, false) )) }; let copy_rows_headers_action = { let cols = columns.clone(); - copy_action!("copy-rows-headers", |_slot, rows| clipboard_message( + copy_action!("copy-rows-headers", |rows| clipboard_message( tablepro_core::export::render_tsv(&cols, &rows, true) )) }; let copy_json_action = { let cols = columns.clone(); - copy_action!("copy-json", |_slot, rows| GridMsg::CopyToClipboard( + copy_action!("copy-json", |rows| GridMsg::CopyToClipboard( tablepro_core::export::render_json(&cols, &rows) )) }; let copy_csv_action = { let cols = columns.clone(); - copy_action!("copy-csv", |_slot, rows| clipboard_message( - tablepro_core::export::render_csv( - &cols, - &rows, - &tablepro_core::export::CsvOptions { - header_row: false, - ..Default::default() - } - ) - )) + copy_action!("copy-csv", |rows| clipboard_message(tablepro_core::export::render_csv( + &cols, + &rows, + &tablepro_core::export::CsvOptions { + header_row: false, + ..Default::default() + } + ))) }; let copy_csv_headers_action = { let cols = columns.clone(); - copy_action!("copy-csv-headers", |_slot, rows| clipboard_message( + copy_action!("copy-csv-headers", |rows| clipboard_message( tablepro_core::export::render_csv(&cols, &rows, &tablepro_core::export::CsvOptions::default()) )) }; let copy_markdown_action = { let cols = columns.clone(); - copy_action!("copy-markdown", |_slot, rows| GridMsg::CopyToClipboard( + copy_action!("copy-markdown", |rows| GridMsg::CopyToClipboard( tablepro_core::export::render_markdown(&cols, &rows) )) }; @@ -284,86 +276,77 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { // clause says how many it left out instead of handing back a // shorter list that quietly selects different rows. let copy_in_clause_action = { - let s = sender.clone(); + let s = sink.clone(); let view = view.clone(); let tab = tab.clone(); - cell_action!("copy-in-clause", |slot| { + cell_action!("copy-in-clause", |cell, row| { let Some(cv) = view.upgrade() else { return }; - let rows = rows_for_menu(&cv, &tab, slot_position(slot)); - let clause = tablepro_core::export::render_in_clause(&rows, slot.col_index); + let rows = rows_for_menu(&cv, &tab, &row); + let clause = tablepro_core::export::render_in_clause(&rows, cell.column()); if clause.sql.is_empty() { - s.send(GridMsg::ShowToast(crate::i18n::gettext( + s(GridMsg::ShowToast(crate::i18n::gettext( "Nothing to copy: an IN clause can't carry NULL or binary values", - ))) - .ok(); + ))); return; } - s.send(GridMsg::CopyToClipboard(clause.sql)).ok(); + s(GridMsg::CopyToClipboard(clause.sql)); if clause.skipped > 0 { - s.send(GridMsg::ShowToast(crate::i18n::gettext_f( + s(GridMsg::ShowToast(crate::i18n::gettext_f( "{n} NULL or binary values left out of the IN clause", &[("n", &clause.skipped.to_string())], - ))) - .ok(); + ))); } }) }; - let copy_column_name_action = send_action!("copy-column-name", |slot| GridMsg::CopyToClipboard( - slot.column_name.clone() - )); - let copy_row_insert_action = send_action!("copy-row-insert", |slot| GridMsg::CopyRowAsInsert { - row_position: slot_position(slot), - }); + let copy_column_name_action = { + let cols = columns.clone(); + send_action!("copy-column-name", |cell, _row| GridMsg::CopyToClipboard( + cols.get(cell.column()) + .map(|column| column.name.clone()) + .unwrap_or_default() + )) + }; + let copy_row_insert_action = send_action!("copy-row-insert", |_cell, row| GridMsg::CopyRowAsInsert { row }); let show_row_json_action = { let cols = columns.clone(); let view = view.clone(); let tab = tab.clone(); - cell_action!("show-row-json", |slot| { + cell_action!("show-row-json", |_cell, row| { let Some(cv) = view.upgrade() else { return }; - let Some(row) = row_at(&cv, slot_position(slot)) else { - return; - }; let text = tablepro_core::export::row_to_json(&cols, &tab.effective_cells(&row)); show_row_json_dialog(&cv, text); }) }; - let set_empty_action = send_action!("set-empty", |slot| GridMsg::SetCellValue { - row_position: slot_position(slot), - col_index: slot.col_index, + let set_empty_action = send_action!("set-empty", |cell, row| GridMsg::SetCellValue { + row, + column: cell.column(), preset: CellPreset::Empty, }); - let set_null_action = send_action!("set-null", |slot| GridMsg::SetCellValue { - row_position: slot_position(slot), - col_index: slot.col_index, + let set_null_action = send_action!("set-null", |cell, row| GridMsg::SetCellValue { + row, + column: cell.column(), preset: CellPreset::Null, }); let export_action = { - let s = sender.clone(); + let s = sink.clone(); let view = view.clone(); let cols = columns.clone(); let tab = tab.clone(); gio::ActionEntry::builder("export") .activate(move |_, _, _| { let Some(cv) = view.upgrade() else { return }; - s.send(GridMsg::ExportResults(export_snapshot(&cv, &cols, truncated, &tab))) - .ok(); + s(GridMsg::ExportResults(export_snapshot(&cv, &cols, truncated, &tab))); }) .build() }; let insert_row_action = { - let s = sender.clone(); + let s = sink.clone(); gio::ActionEntry::builder("insert-row") - .activate(move |_, _, _| { - s.send(GridMsg::InsertRow).ok(); - }) + .activate(move |_, _, _| s(GridMsg::InsertRow)) .build() }; - let delete_row_action = send_action!("delete-row", |slot| GridMsg::DeleteRowAt { - row_position: slot_position(slot), - }); - let duplicate_row_action = send_action!("duplicate-row", |slot| GridMsg::DuplicateRow { - row_position: slot_position(slot), - }); + let delete_row_action = send_action!("delete-row", |_cell, row| GridMsg::DeleteRowAt { row }); + let duplicate_row_action = send_action!("duplicate-row", |_cell, row| GridMsg::DuplicateRow { row }); let mut entries = vec![ copy_action, diff --git a/linux/crates/app/src/ui/grid/grid_msg.rs b/linux/crates/app/src/ui/grid/grid_msg.rs index 0a4d1ba101..9cb92366ea 100644 --- a/linux/crates/app/src/ui/grid/grid_msg.rs +++ b/linux/crates/app/src/ui/grid/grid_msg.rs @@ -1,6 +1,12 @@ +use std::rc::Rc; + use tablepro_core::QueryResult; -use super::CellPreset; +use super::{CellPreset, RowObject}; + +/// Where a grid sends its events. The owner holds the grid, so the +/// closure holds the owner weakly. +pub type GridSink = Rc; #[derive(Debug)] pub enum GridMsg { @@ -8,29 +14,29 @@ pub enum GridMsg { /// another column fires both sort notifies, and a hint would flip twice. SortChanged(usize, bool), CellEdited { - row_position: u32, - col_index: usize, - new_value: String, + row: RowObject, + column: usize, + input: String, }, CopyToClipboard(String), ShowToast(String), CopyRowAsInsert { - row_position: u32, + row: RowObject, }, /// An intent, not a `Value`: the grid does not know the column's /// declared type, and `Text("")` in an `int` or a `date` column is an /// UPDATE the server rejects. SetCellValue { - row_position: u32, - col_index: usize, + row: RowObject, + column: usize, preset: CellPreset, }, ExportResults(QueryResult), DeleteRowAt { - row_position: u32, + row: RowObject, }, InsertRow, DuplicateRow { - row_position: u32, + row: RowObject, }, } diff --git a/linux/crates/app/src/ui/grid/grid_row_id.rs b/linux/crates/app/src/ui/grid/grid_row_id.rs new file mode 100644 index 0000000000..1abd49557b --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_row_id.rs @@ -0,0 +1,81 @@ +use crate::services::change_tracker::RowKey; + +/// What a grid row is to the tab that tracks its edits. Fixed when the +/// row object is made, so an event that reaches the row after others +/// moved around it still names the same row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GridRowId { + /// An editor result row, which nothing tracks. + Result, + /// A table row with no usable key, which can be read but not edited. + Unkeyed, + Persisted(RowKey), + Draft(u64), +} + +impl GridRowId { + /// The key of a persisted row, which is what an UPDATE or DELETE + /// names it by. + pub fn key(&self) -> Option<&RowKey> { + match self { + Self::Persisted(key) => Some(key), + Self::Result | Self::Unkeyed | Self::Draft(_) => None, + } + } + + pub fn draft_id(&self) -> Option { + match self { + Self::Draft(draft_id) => Some(*draft_id), + Self::Result | Self::Unkeyed | Self::Persisted(_) => None, + } + } + + /// The key the tracker files this row's pending changes under. + pub fn tracker_key(&self) -> Option { + match self { + Self::Persisted(key) => Some(key.clone()), + Self::Draft(draft_id) => Some(RowKey::Draft(*draft_id)), + Self::Result | Self::Unkeyed => None, + } + } + + /// Whether the tracker's `key` is this row, without building one. + pub fn is_tracked_as(&self, key: &RowKey) -> bool { + match (self, key) { + (Self::Persisted(own), _) => own == key, + (Self::Draft(own), RowKey::Draft(other)) => own == other, + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + + use super::GridRowId; + use crate::services::change_tracker::RowKey; + + #[test] + fn grid_row_id_key_is_none_for_result_and_draft_rows() { + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + + assert_eq!(GridRowId::Result.key(), None); + assert_eq!(GridRowId::Draft(3).key(), None); + assert_eq!(GridRowId::Unkeyed.key(), None); + assert_eq!(GridRowId::Persisted(key.clone()).key(), Some(&key)); + assert_eq!(GridRowId::Draft(3).tracker_key(), Some(RowKey::Draft(3))); + assert_eq!(GridRowId::Result.tracker_key(), None); + } + + #[test] + fn a_draft_is_never_taken_for_a_persisted_row() { + let key = RowKey::from_pk_values(&[Value::Int(3)]).expect("a key"); + + assert!(GridRowId::Draft(3).is_tracked_as(&RowKey::Draft(3))); + assert!(!GridRowId::Draft(3).is_tracked_as(&key)); + assert!(GridRowId::Persisted(key.clone()).is_tracked_as(&key)); + assert!(!GridRowId::Persisted(key).is_tracked_as(&RowKey::Draft(3))); + assert!(!GridRowId::Unkeyed.is_tracked_as(&RowKey::Draft(3))); + } +} diff --git a/linux/crates/app/src/ui/grid/inline_edit.rs b/linux/crates/app/src/ui/grid/inline_edit.rs index 7feea78a8a..a5390ab3bf 100644 --- a/linux/crates/app/src/ui/grid/inline_edit.rs +++ b/linux/crates/app/src/ui/grid/inline_edit.rs @@ -1,25 +1,25 @@ +use std::rc::Rc; + use gtk4::prelude::*; use gtk4::{self as gtk, glib}; -use super::GridMsg; -use super::cell_editor::CellEditor; use super::cell_editor_kind::CellEditorKind; use super::cell_popovers::{show_calendar_popover, show_json_popover, show_spin_button_popover}; use super::display_text::editable_null_sentinel; -use super::widget_slot::{POSITION_SLOT, PREEDIT_SLOT, SNAPSHOT_SLOT}; - -#[derive(Debug)] -pub(super) struct EditSnapshot { - position: u32, - original: String, -} +use super::{EditSnapshot, GridCell, GridMsg, GridSink}; /// Clears the `` sentinel first, so the user does not type over it. -pub(super) fn enter_edit_mode(label: &CellEditor) { - if label.text().as_str() == editable_null_sentinel() { - label.set_text(""); +pub(crate) fn enter_edit_mode(cell: &GridCell) { + if cell.lock().is_some() { + return; + } + let Some(editor) = cell.editor() else { + return; + }; + if editor.text().as_str() == editable_null_sentinel() { + editor.set_text(""); } - label.start_editing(); + editor.start_editing(); } /// `child_focus` on the window is what a real Tab press does. @@ -31,31 +31,20 @@ fn move_focus(widget: &impl IsA, direction: gtk::DirectionType) { window.child_focus(direction); } -pub(super) fn install_edit_triggers( - label: &CellEditor, - col_index: usize, - sender: relm4::Sender, - editor_kind: CellEditorKind, -) { - let trigger: std::rc::Rc = match editor_kind { - CellEditorKind::Text => std::rc::Rc::new(|l: &CellEditor| enter_edit_mode(l)), - CellEditorKind::Date => { - let sender = sender.clone(); - std::rc::Rc::new(move |l| show_calendar_popover(l, col_index, &sender)) - } - CellEditorKind::Int => { - let sender = sender.clone(); - std::rc::Rc::new(move |l| show_spin_button_popover(l, col_index, &sender, false)) - } - CellEditorKind::Float => { - let sender = sender.clone(); - std::rc::Rc::new(move |l| show_spin_button_popover(l, col_index, &sender, true)) - } - CellEditorKind::Json => { - let sender = sender.clone(); - std::rc::Rc::new(move |l| show_json_popover(l, col_index, &sender)) +pub(super) fn install_edit_triggers(cell: &GridCell, sink: GridSink, editor_kind: CellEditorKind) { + let open: Rc = match editor_kind { + CellEditorKind::Text => Rc::new(enter_edit_mode), + CellEditorKind::Date => Rc::new(move |cell| show_calendar_popover(cell, &sink)), + CellEditorKind::Int => Rc::new(move |cell| show_spin_button_popover(cell, &sink, false)), + CellEditorKind::Float => Rc::new(move |cell| show_spin_button_popover(cell, &sink, true)), + CellEditorKind::Json => Rc::new(move |cell| show_json_popover(cell, &sink)), + }; + let trigger = move |cell: &GridCell| { + if cell.lock().is_none() { + open(cell); } }; + let trigger = Rc::new(trigger); // Editing takes a double-click, since a single click in a grid // selects the row. Capture phase, because the column view's row @@ -70,46 +59,48 @@ pub(super) fn install_edit_triggers( if n_press != 2 { return; } - let Some(label) = gesture.widget().and_downcast::() else { + let Some(cell) = gesture.widget().and_downcast::() else { return; }; gesture.set_state(gtk::EventSequenceState::Claimed); - trigger_for_press(&label); + trigger_for_press(&cell); }); - label.add_controller(gesture); + cell.add_controller(gesture); // While editing, Return and Escape belong to the CellEditor's entry. let controller = gtk::EventControllerKey::new(); - let trigger_for_key = trigger; controller.connect_key_pressed(move |controller, keyval, _, modifiers| { - let Some(label) = controller.widget().and_downcast::() else { + let Some(cell) = controller.widget().and_downcast::() else { return glib::Propagation::Proceed; }; - let editing = label.is_editing(); + let Some(editor) = cell.editor() else { + return glib::Propagation::Proceed; + }; + let editing = editor.is_editing(); let shift = modifiers.contains(gtk::gdk::ModifierType::SHIFT_MASK); if !editing { match keyval { gtk::gdk::Key::F2 | gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter => { - trigger_for_key(&label); + trigger(&cell); return glib::Propagation::Stop; } // Left to GTK, Tab leaves the grid for the paginator. gtk::gdk::Key::Tab if !shift => { - move_focus(&label, gtk::DirectionType::TabForward); + move_focus(&cell, gtk::DirectionType::TabForward); return glib::Propagation::Stop; } gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab if shift => { - move_focus(&label, gtk::DirectionType::TabBackward); + move_focus(&cell, gtk::DirectionType::TabBackward); return glib::Propagation::Stop; } // The column view moves between rows only. gtk::gdk::Key::Right => { - move_focus(&label, gtk::DirectionType::TabForward); + move_focus(&cell, gtk::DirectionType::TabForward); return glib::Propagation::Stop; } gtk::gdk::Key::Left => { - move_focus(&label, gtk::DirectionType::TabBackward); + move_focus(&cell, gtk::DirectionType::TabBackward); return glib::Propagation::Stop; } _ => {} @@ -119,69 +110,80 @@ pub(super) fn install_edit_triggers( match keyval { gtk::gdk::Key::Tab if !shift => { - label.stop_editing(true); - move_focus(&label, gtk::DirectionType::TabForward); + editor.stop_editing(true); + move_focus(&cell, gtk::DirectionType::TabForward); glib::Propagation::Stop } gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab if shift => { - label.stop_editing(true); - move_focus(&label, gtk::DirectionType::TabBackward); + editor.stop_editing(true); + move_focus(&cell, gtk::DirectionType::TabBackward); glib::Propagation::Stop } _ => glib::Propagation::Proceed, } }); - label.add_controller(controller); + cell.add_controller(controller); } -/// Focus moving to an IME popover ends the edit mid-composition, so the -/// commit waits until the preedit clears. -pub(super) fn install_edit_commit_handler(label: &CellEditor, col_index: usize, sender: relm4::Sender) { - { - let text = label.entry(); - // The entry is the cell's own child, so it holds the cell weakly. - let label_for_preedit = label.downgrade(); - let sender_for_preedit = sender.clone(); - text.connect_preedit_changed(move |_t, preedit| { - let Some(label_for_preedit) = label_for_preedit.upgrade() else { - return; - }; - let active = !preedit.is_empty(); - PREEDIT_SLOT.set(&label_for_preedit, active); - // The edit ended during the composition, so this is its commit. - if !active && !label_for_preedit.is_editing() { - commit_cell_edit(&label_for_preedit, col_index, &sender_for_preedit); - } - }); - } +/// The row is taken when the edit begins, so a commit after rows moved +/// still lands on it. Focus moving to an IME popover ends the edit +/// mid-composition, so the commit waits until the preedit clears. +pub(super) fn install_edit_commit_handler(cell: &GridCell, sink: GridSink) { + let Some(editor) = cell.editor() else { + return; + }; + // The editor is the cell's own child, so its handlers hold the cell + // weakly. + let cell_for_preedit = cell.downgrade(); + let sink_for_preedit = sink.clone(); + editor.entry().connect_preedit_changed(move |_, preedit| { + let Some(cell) = cell_for_preedit.upgrade() else { + return; + }; + let active = !preedit.is_empty(); + cell.set_preedit_active(active); + // The edit ended during the composition, so this is its commit. + if !active && !cell.editor().is_some_and(|editor| editor.is_editing()) { + commit_cell_edit(&cell, &sink_for_preedit); + } + }); - label.connect_editing_notify(move |label| { - if label.is_editing() { - let position = POSITION_SLOT.get(label).unwrap_or(0); - let original = label.text().to_string(); - SNAPSHOT_SLOT.set(label, EditSnapshot { position, original }); + let cell_for_edit = cell.downgrade(); + editor.connect_editing_notify(move |editor| { + let Some(cell) = cell_for_edit.upgrade() else { + return; + }; + if editor.is_editing() { + if let Some(row) = cell.row() { + cell.begin_edit(EditSnapshot { + row, + column: cell.column(), + original_text: editor.text().to_string(), + }); + } return; } - if PREEDIT_SLOT.get(label).unwrap_or(false) { + if cell.preedit_active() { return; } - commit_cell_edit(label, col_index, &sender); + commit_cell_edit(&cell, &sink); }); } -fn commit_cell_edit(label: &CellEditor, col_index: usize, sender: &relm4::Sender) { - let Some(snap) = SNAPSHOT_SLOT.take(label) else { +fn commit_cell_edit(cell: &GridCell, sink: &GridSink) { + let Some(snapshot) = cell.take_edit() else { return; }; - let new_value = label.text().to_string(); - if new_value == snap.original { + let Some(editor) = cell.editor() else { + return; + }; + let input = editor.text().to_string(); + if input == snapshot.original_text { return; } - sender - .send(GridMsg::CellEdited { - row_position: snap.position, - col_index, - new_value, - }) - .ok(); + sink(GridMsg::CellEdited { + row: snapshot.row, + column: snapshot.column, + input, + }); } diff --git a/linux/crates/app/src/ui/grid/mod.rs b/linux/crates/app/src/ui/grid/mod.rs index 828058efac..82ab20fd7b 100644 --- a/linux/crates/app/src/ui/grid/mod.rs +++ b/linux/crates/app/src/ui/grid/mod.rs @@ -1,26 +1,35 @@ +mod cell_editability; mod cell_editor; mod cell_editor_kind; +mod cell_lock; mod cell_popovers; mod cell_preset; mod column_factory; mod column_width_binding; mod context_menu; mod display_text; +mod edit_snapshot; +mod grid_cell; mod grid_menus; mod grid_msg; +mod grid_row_id; mod inline_edit; mod row_json_dialog; mod row_object; mod selection; mod tab_grid_context; -mod widget_slot; +pub(crate) use self::cell_editability::cell_editability; pub(crate) use self::cell_editor::CellEditor; +pub(crate) use self::cell_lock::CellLock; pub(crate) use self::cell_preset::CellPreset; pub(crate) use self::column_factory::build_column_view; pub(crate) use self::column_width_binding::ColumnWidthBinding; -pub(crate) use self::display_text::editable_null_sentinel; -pub(crate) use self::grid_msg::GridMsg; +pub(crate) use self::edit_snapshot::EditSnapshot; +pub(crate) use self::grid_cell::GridCell; +pub(crate) use self::grid_msg::{GridMsg, GridSink}; +pub(crate) use self::grid_row_id::GridRowId; +pub(crate) use self::inline_edit::enter_edit_mode; pub(crate) use self::row_object::RowObject; -pub(crate) use self::selection::{export_snapshot, focused_cell_coords, selected_positions}; +pub(crate) use self::selection::{export_snapshot, selected_positions}; pub(crate) use self::tab_grid_context::TabGridContext; diff --git a/linux/crates/app/src/ui/grid/row_json_dialog.rs b/linux/crates/app/src/ui/grid/row_json_dialog.rs index 2909d6ed7b..6f513bb498 100644 --- a/linux/crates/app/src/ui/grid/row_json_dialog.rs +++ b/linux/crates/app/src/ui/grid/row_json_dialog.rs @@ -1,7 +1,7 @@ use gtk4 as gtk; use gtk4::prelude::*; -use relm4::adw; -use relm4::adw::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; use sourceview5::prelude::*; pub(super) fn show_row_json_dialog(parent: &impl IsA, json: String) { diff --git a/linux/crates/app/src/ui/grid/row_object.rs b/linux/crates/app/src/ui/grid/row_object.rs index bb30abab48..657be5b6e0 100644 --- a/linux/crates/app/src/ui/grid/row_object.rs +++ b/linux/crates/app/src/ui/grid/row_object.rs @@ -1,19 +1,23 @@ -use std::cell::{Cell, RefCell}; +use std::cell::OnceCell; +use std::sync::Arc; use gtk4::glib; use gtk4::subclass::prelude::*; use tablepro_core::Value; +use super::GridRowId; +use crate::services::change_tracker::RowKey; + mod imp { use super::*; #[derive(Default)] pub struct RowObject { - pub cells: RefCell>, - /// Tells a draft apart from a persisted row whose key columns - /// happen to be NULL. - pub draft_id: Cell>, + pub id: OnceCell, + /// The row as it was read. A draft has none: its cells are the + /// tracker's. + pub cells: OnceCell>, } #[glib::object_subclass] @@ -29,40 +33,86 @@ glib::wrapper! { pub struct RowObject(ObjectSubclass); } +/// What a row made by `glib::Object::new` rather than a constructor +/// here reads as: nothing to edit. +static UNNAMED: GridRowId = GridRowId::Result; + impl RowObject { - pub fn new(cells: Vec) -> Self { - let obj: Self = glib::Object::new(); - *obj.imp().cells.borrow_mut() = cells; - obj + fn with(id: GridRowId, cells: Option>) -> Self { + let row: Self = glib::Object::new(); + let _ = row.imp().id.set(id); + if let Some(cells) = cells { + let _ = row.imp().cells.set(cells); + } + row } - pub fn new_draft(draft_id: u64, cells: Vec) -> Self { - let obj = Self::new(cells); - obj.imp().draft_id.set(Some(draft_id)); - obj + /// A row read from a table. `None` for a row with no usable key. + pub fn persisted(key: Option, cells: Arc<[Value]>) -> Self { + let id = match key { + Some(key) => GridRowId::Persisted(key), + None => GridRowId::Unkeyed, + }; + Self::with(id, Some(cells)) } - pub fn draft_id(&self) -> Option { - self.imp().draft_id.get() + pub fn draft(draft_id: u64) -> Self { + Self::with(GridRowId::Draft(draft_id), None) } - pub fn cell_value(&self, idx: usize) -> Value { - self.imp().cells.borrow().get(idx).cloned().unwrap_or(Value::Null) + pub fn result(cells: Arc<[Value]>) -> Self { + Self::with(GridRowId::Result, Some(cells)) } - pub fn cells_clone(&self) -> Vec { - self.imp().cells.borrow().clone() + /// A new object for the same row. The list item manager skips the + /// rebind for an items-changed that keeps the same item, so a row + /// whose pending state changed is swapped for one of these. + pub fn replacement(&self) -> Self { + Self::with(self.id().clone(), self.imp().cells.get().cloned()) } - /// Spares a hot path the copy `cells_clone` makes. - pub fn with_cells(&self, f: impl FnOnce(&[Value]) -> R) -> R { - f(&self.imp().cells.borrow()) + pub fn id(&self) -> &GridRowId { + self.imp().id.get().unwrap_or(&UNNAMED) } - pub fn set_cell(&self, idx: usize, value: Value) { - let mut cells = self.imp().cells.borrow_mut(); - if idx < cells.len() { - cells[idx] = value; - } + pub fn key(&self) -> Option<&RowKey> { + self.id().key() + } + + pub fn draft_id(&self) -> Option { + self.id().draft_id() + } + + pub fn cells(&self) -> Option> { + self.imp().cells.get().cloned() + } + + pub fn cell(&self, column: usize) -> Option<&Value> { + self.imp().cells.get()?.get(column) + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + + use super::RowObject; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::GridRowId; + + #[test] + fn a_row_keeps_the_identity_it_was_made_with() { + let key = RowKey::from_pk_values(&[Value::Int(2)]).expect("a key"); + let persisted = RowObject::persisted(Some(key.clone()), vec![Value::Int(2)].into()); + let unkeyed = RowObject::persisted(None, vec![Value::Int(2)].into()); + let draft = RowObject::draft(5); + + assert_eq!(persisted.key(), Some(&key)); + assert_eq!(persisted.cell(0), Some(&Value::Int(2))); + assert_eq!(unkeyed.id(), &GridRowId::Unkeyed); + assert_eq!(draft.draft_id(), Some(5)); + assert!(draft.cells().is_none(), "a draft's cells are the tracker's"); + assert_eq!(persisted.replacement().id(), persisted.id()); + assert_eq!(persisted.replacement().cells(), persisted.cells()); } } diff --git a/linux/crates/app/src/ui/grid/selection.rs b/linux/crates/app/src/ui/grid/selection.rs index 1227844066..d6532de30f 100644 --- a/linux/crates/app/src/ui/grid/selection.rs +++ b/linux/crates/app/src/ui/grid/selection.rs @@ -4,7 +4,6 @@ use gtk4::prelude::*; use tablepro_core::column::ResultColumn; use tablepro_core::{QueryResult, Value}; -use super::widget_slot::{COLUMN_SLOT, POSITION_SLOT}; use super::{RowObject, TabGridContext}; pub(crate) fn selected_positions(selection: >k::MultiSelection) -> Vec { @@ -38,20 +37,23 @@ pub(super) fn select_row_for_menu(column_view: >k::ColumnView, position: u32) selection.select_item(position, true); } -/// `clicked` is the fallback for the keyboard path on a grid whose -/// selection is empty. -pub(super) fn rows_for_menu(column_view: >k::ColumnView, ctx: &TabGridContext, clicked: u32) -> Vec> { - let mut positions = selection_of(column_view) - .map(|s| selected_positions(&s)) - .unwrap_or_default(); - if positions.is_empty() { - positions.push(clicked); +/// The selection when it holds the clicked row, else the clicked row +/// alone, so rows that moved since the click never stand in for it. +pub(super) fn rows_for_menu( + column_view: >k::ColumnView, + ctx: &TabGridContext, + clicked: &RowObject, +) -> Vec> { + let selected: Vec = selection_of(column_view) + .map(|selection| selected_positions(&selection)) + .unwrap_or_default() + .into_iter() + .filter_map(|position| row_at(column_view, position)) + .collect(); + if !selected.contains(clicked) { + return vec![ctx.effective_cells(clicked)]; } - positions - .iter() - .filter_map(|p| row_at(column_view, *p)) - .map(|r| ctx.effective_cells(&r)) - .collect() + selected.iter().map(|row| ctx.effective_cells(row)).collect() } /// The paginator's Export button and the menu's Export Results both @@ -71,13 +73,3 @@ pub(crate) fn export_snapshot( }; QueryResult::new(columns.to_vec(), rows).truncated(truncated) } - -pub(crate) fn focused_cell_coords(widget: &impl IsA) -> Option<(u32, usize)> { - let root = widget.root()?; - let window = root.dynamic_cast::().ok()?; - // `RootExt::focus` would return the window itself. - let focused = gtk4::prelude::GtkWindowExt::focus(&window)?; - let position = POSITION_SLOT.get(&focused)?; - let column = COLUMN_SLOT.get(&focused)?; - Some((position, column)) -} diff --git a/linux/crates/app/src/ui/grid/tab_grid_context.rs b/linux/crates/app/src/ui/grid/tab_grid_context.rs index 4fd4064aa1..f15e6ffae8 100644 --- a/linux/crates/app/src/ui/grid/tab_grid_context.rs +++ b/linux/crates/app/src/ui/grid/tab_grid_context.rs @@ -1,40 +1,54 @@ use tablepro_core::Value; +use tablepro_core::meta::RowIdentity; -use super::{ColumnWidthBinding, RowObject}; +use super::{ColumnWidthBinding, GridRowId, RowObject}; use crate::services::cell_overlay::CellOverlay; use crate::services::change_tracker::{CellState, RowKey, RowState}; use crate::services::tab_tracker_handle::TabTrackerHandle; /// `tracker` and `column_widths` are `None` for a grid nothing tracks, /// such as editor results. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct TabGridContext { pub tracker: Option, - pub pk_col_indices: Vec, + /// Whether the grid offers edits at all. A read-only session's grid + /// does not. + pub editable: bool, + /// What names the grid's rows, which decides the columns a saved + /// row cannot change. + pub identity: RowIdentity, pub column_widths: Option, } +impl Default for TabGridContext { + fn default() -> Self { + Self { + tracker: None, + editable: false, + identity: RowIdentity::Unordered, + column_widths: None, + } + } +} + impl TabGridContext { - /// `None` when nothing tracks the grid or a persisted row has no - /// usable primary key. + /// `None` when nothing tracks the grid or the row has no key. fn tracked_key(&self, row: &RowObject) -> Option<(&TabTrackerHandle, RowKey)> { - let tracker = self.tracker.as_ref()?; - let key = match row.draft_id() { - Some(draft_id) => RowKey::Draft(draft_id), - None => { - let pk_values: Vec = self.pk_col_indices.iter().map(|&i| row.cell_value(i)).collect(); - RowKey::from_pk_values(&pk_values)? - } - }; - Some((tracker, key)) + Some((self.tracker.as_ref()?, row.id().tracker_key()?)) } /// Every copy and export path reads this rather than - /// `RowObject::cells_clone`, which holds a persisted row as fetched. + /// `RowObject::cells`, which holds a saved row as fetched and a + /// draft not at all. pub(crate) fn effective_cells(&self, row: &RowObject) -> Vec { - let raw = row.cells_clone(); + if let (GridRowId::Draft(draft_id), Some(tracker)) = (row.id(), self.tracker.as_ref()) { + return tracker.draft_values(*draft_id).unwrap_or_default(); + } + let Some(raw) = row.cells() else { + return Vec::new(); + }; let Some((tracker, key)) = self.tracked_key(row) else { - return raw; + return raw.to_vec(); }; raw.iter() .enumerate() @@ -43,7 +57,7 @@ impl TabGridContext { } pub(super) fn bind_state(&self, row: &RowObject, col: usize) -> (Value, Vec<&'static str>) { - let raw = row.cell_value(col); + let raw = row.cell(col).cloned().unwrap_or(Value::Null); let Some((tracker, key)) = self.tracked_key(row) else { return (raw, Vec::new()); }; @@ -74,37 +88,66 @@ mod tests { use super::{RowObject, TabGridContext}; use crate::services::change_tracker::RowKey; use crate::services::tab_tracker_handle::TabTrackerHandle; + use crate::services::tracker_delta::VisualChange; + use crate::services::tracker_edit::TrackerEdit; + + fn context(tracker: &TabTrackerHandle) -> TabGridContext { + TabGridContext { + tracker: Some(tracker.clone()), + ..TabGridContext::default() + } + } + + fn insert_draft(tracker: &TabTrackerHandle, values: Vec) -> u64 { + let delta = tracker.apply(TrackerEdit::Insert { values }); + let Some(VisualChange::InsertDraft { draft_id }) = delta.visual.into_iter().next() else { + panic!("an insert adds a draft row"); + }; + draft_id + } #[test] fn a_tracked_draft_carries_its_insert_tint_and_error_flash() { let tracker = TabTrackerHandle::default(); - let delta = tracker.apply(crate::services::tracker_edit::TrackerEdit::Insert { - values: vec![Value::Null], - }); - let Some(crate::services::tracker_delta::VisualChange::InsertDraft { draft_id, values }) = - delta.visual.into_iter().next() - else { - panic!("an insert adds a draft row"); - }; + let draft_id = insert_draft(&tracker, vec![Value::Int(4)]); let _ = tracker.flash_error_row(RowKey::Draft(draft_id)); - let context = TabGridContext { - tracker: Some(tracker), - pk_col_indices: vec![0], - column_widths: None, - }; - let (_, classes) = context.bind_state(&RowObject::new_draft(draft_id, values), 0); + let (value, classes) = context(&tracker).bind_state(&RowObject::draft(draft_id), 0); + assert_eq!(value, Value::Int(4), "a draft's cells are read from the tracker"); assert_eq!(classes, vec!["tp-row-pending-insert", "tp-row-leftmost-error-flash"]); } #[test] fn an_untracked_grid_draws_the_fetched_value_plainly() { - let row = RowObject::new(vec![Value::Int(1)]); + let row = RowObject::result(vec![Value::Int(1)].into()); let (value, classes) = TabGridContext::default().bind_state(&row, 0); assert_eq!(value, Value::Int(1)); assert!(classes.is_empty()); } + + #[test] + fn effective_cells_include_pending_edits_and_draft_cells() { + let tracker = TabTrackerHandle::default(); + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + let _ = tracker.apply(TrackerEdit::Cell { + row_key: key.clone(), + col: 1, + original: Value::Text("old".into()), + value: Value::Text("new".into()), + }); + let draft_id = insert_draft(&tracker, vec![Value::Null, Value::Text("draft".into())]); + let saved = RowObject::persisted(Some(key), vec![Value::Int(1), Value::Text("old".into())].into()); + + assert_eq!( + context(&tracker).effective_cells(&saved), + vec![Value::Int(1), Value::Text("new".into())] + ); + assert_eq!( + context(&tracker).effective_cells(&RowObject::draft(draft_id)), + vec![Value::Null, Value::Text("draft".into())] + ); + } } diff --git a/linux/crates/app/src/ui/grid/widget_slot.rs b/linux/crates/app/src/ui/grid/widget_slot.rs deleted file mode 100644 index 57f8852d38..0000000000 --- a/linux/crates/app/src/ui/grid/widget_slot.rs +++ /dev/null @@ -1,46 +0,0 @@ -use gtk4 as gtk; -use gtk4::prelude::*; - -use super::inline_edit::EditSnapshot; - -pub(super) struct WidgetSlot { - key: &'static str, - _phantom: std::marker::PhantomData, -} - -impl WidgetSlot { - const fn new(key: &'static str) -> Self { - Self { - key, - _phantom: std::marker::PhantomData, - } - } - - pub(super) fn set(&self, widget: &impl IsA, value: T) { - unsafe { widget.set_data(self.key, value) }; - } - - pub(super) fn take(&self, widget: &impl IsA) -> Option { - unsafe { widget.steal_data::(self.key) } - } -} - -impl WidgetSlot { - pub(super) fn get(&self, widget: &impl IsA) -> Option { - unsafe { widget.data::(self.key).map(|p| *p.as_ref()) } - } -} - -pub(super) const POSITION_SLOT: WidgetSlot = WidgetSlot::new("tp-position"); -pub(super) const SNAPSHOT_SLOT: WidgetSlot = WidgetSlot::new("tp-snapshot"); -pub(super) const COLUMN_SLOT: WidgetSlot = WidgetSlot::new("tp-column"); -/// Set while the bind drives a check button, which would otherwise echo -/// back as a CellEdited of the value it just drew. -pub(super) const SUPPRESS_SLOT: WidgetSlot = WidgetSlot::new("tp-suppress-toggle"); -/// The cell's open editor popover. Unbind pops it down, or the recycled -/// cell is finalized with a GtkPopover child still in it. -pub(super) const POPOVER_SLOT: WidgetSlot = WidgetSlot::new("tp-popover"); -/// Set while an IME composition is in flight: focus moving to the IME -/// popover ends the edit, and committing then would send the text from -/// before the composition. -pub(super) const PREEDIT_SLOT: WidgetSlot = WidgetSlot::new("tp-preedit-active"); diff --git a/linux/data/resources/style.css b/linux/data/resources/style.css index 8dd28e9f93..3834af2689 100644 --- a/linux/data/resources/style.css +++ b/linux/data/resources/style.css @@ -34,8 +34,8 @@ label.tp-null-sentinel { against the selected-row highlight. A 2px inset accent ring is the spreadsheet-standard focused-cell signal. The selectors stay explicit so the ring never stacks on a GtkCheckButton, which paints its own. */ -columnview > listview > row > cell:focus-within > label, -columnview > listview > row > cell:focus-within > .tp-cell-editor { +columnview > listview > row > cell:focus-within > tp-grid-cell > label, +columnview > listview > row > cell:focus-within > tp-grid-cell > .tp-cell-editor { box-shadow: inset 0 0 0 2px var(--accent-color); border-radius: 2px; } @@ -71,8 +71,8 @@ columnview > listview > row > cell:focus-within > .tp-cell-editor { color: var(--error-fg-color); } - columnview > listview > row > cell:focus-within > label, - columnview > listview > row > cell:focus-within > .tp-cell-editor { + columnview > listview > row > cell:focus-within > tp-grid-cell > label, + columnview > listview > row > cell:focus-within > tp-grid-cell > .tp-cell-editor { box-shadow: inset 0 0 0 3px var(--accent-color); } } diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 69bcb056df..cca75ac37e 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -131,23 +131,27 @@ crates/app/src/ui/error_text/tls_remedy.rs crates/app/src/ui/export_dialog.rs crates/app/src/ui/file_read.rs crates/app/src/ui/filter_strip.rs +crates/app/src/ui/grid/cell_editability.rs crates/app/src/ui/grid/cell_editor.rs crates/app/src/ui/grid/cell_editor_kind.rs +crates/app/src/ui/grid/cell_lock.rs crates/app/src/ui/grid/cell_popovers.rs crates/app/src/ui/grid/cell_preset.rs crates/app/src/ui/grid/column_factory.rs crates/app/src/ui/grid/column_width_binding.rs crates/app/src/ui/grid/context_menu.rs crates/app/src/ui/grid/display_text.rs +crates/app/src/ui/grid/edit_snapshot.rs +crates/app/src/ui/grid/grid_cell.rs crates/app/src/ui/grid/grid_menus.rs crates/app/src/ui/grid/grid_msg.rs +crates/app/src/ui/grid/grid_row_id.rs crates/app/src/ui/grid/inline_edit.rs crates/app/src/ui/grid/mod.rs crates/app/src/ui/grid/row_json_dialog.rs crates/app/src/ui/grid/row_object.rs crates/app/src/ui/grid/selection.rs crates/app/src/ui/grid/tab_grid_context.rs -crates/app/src/ui/grid/widget_slot.rs crates/app/src/ui/handler_guard.rs crates/app/src/ui/history_dialog.rs crates/app/src/ui/icons.rs From b4536ad1cfdb64399c3a2e75b07b678fceeae0aa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:44:10 +0700 Subject: [PATCH 128/178] fix(linux): read MySQL catalogue expressions and MariaDB EXTRA as SQL --- linux/crates/drivers/mysql/src/catalog.rs | 64 ++++++++++++++++++- .../mysql/src/catalog/escaped_expression.rs | 44 +++++++++++++ .../drivers/mysql/src/catalog/mysql_extra.rs | 33 +++++++++- 3 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 linux/crates/drivers/mysql/src/catalog/escaped_expression.rs diff --git a/linux/crates/drivers/mysql/src/catalog.rs b/linux/crates/drivers/mysql/src/catalog.rs index a6f2e84cb0..ef9a1400e2 100644 --- a/linux/crates/drivers/mysql/src/catalog.rs +++ b/linux/crates/drivers/mysql/src/catalog.rs @@ -3,6 +3,7 @@ mod catalog_cells; mod column_type_scan_error; +mod escaped_expression; mod mysql_column_row; mod mysql_column_type; mod mysql_extra; @@ -31,6 +32,7 @@ use tablepro_core::value::Value; use crate::flavour::MySqlFlavour; use crate::mysql_timestamp_mode::MySqlTimestampMode; +use escaped_expression::unescape_expression; use mysql_column_type::scan; /// The catalogue's facts that decide a kind, apart from the rest of the @@ -229,8 +231,8 @@ pub(crate) fn column_definition( .as_ref() .map(|expression| SqlExpression::from_catalog_text(expression.as_str())), generated: generated.and_then(|storage| { - row.generation_expression.as_ref().map(|expression| GeneratedColumn { - expression: SqlExpression::from_catalog_text(expression.as_str()), + row.generation_expression.as_deref().map(|expression| GeneratedColumn { + expression: catalog_expression(expression, flavour), storage, }) }), @@ -249,6 +251,15 @@ pub(crate) fn column_definition( ColumnDefinition::new(column_info(row, flavour), attributes) } +/// An expression from `information_schema.COLUMNS` as SQL a statement +/// can splice. +fn catalog_expression(listed: &str, flavour: MySqlFlavour) -> SqlExpression { + match flavour { + MySqlFlavour::MySql => SqlExpression::from_catalog_text(unescape_expression(listed)), + MySqlFlavour::MariaDb => SqlExpression::from_catalog_text(listed), + } +} + /// A DEFAULT as a value the insert path can bind where it is a literal, /// and as the server's expression otherwise. /// @@ -266,7 +277,7 @@ pub(crate) fn classify_default( }; let expression = || ColumnDefault::Expression(SqlExpression::from_catalog_text(text)); match flavour { - MySqlFlavour::MySql if extra.default_generated => expression(), + MySqlFlavour::MySql if extra.default_generated => ColumnDefault::Expression(catalog_expression(text, flavour)), MySqlFlavour::MySql => match &column_type.kind { // The text is the value, spaces and all, and an empty one is // the empty string rather than no default. @@ -534,6 +545,10 @@ mod tests { classify_default(Some("CURRENT_TIMESTAMP"), &stamp, &generated, mysql), ColumnDefault::Expression(expression) if expression.as_sql() == "CURRENT_TIMESTAMP" )); + assert!(matches!( + classify_default(Some(r"concat(_utf8mb4\'it\\\'s\')"), &text, &generated, mysql), + ColumnDefault::Expression(expression) if expression.as_sql() == r"concat(_utf8mb4'it\'s')" + )); assert_eq!(classify_default(None, &integer, &plain, mysql), ColumnDefault::None); assert_eq!( @@ -553,4 +568,47 @@ mod tests { ColumnDefault::Expression(expression) if expression.as_sql() == "current_timestamp()" )); } + + #[test] + fn a_generation_expression_reads_as_the_sql_each_server_runs() { + let row = |extra: &str, expression: &str| MySqlColumnRow { + name: "label".to_owned(), + data_type: "varchar".to_owned(), + column_type: "varchar(30)".to_owned(), + nullable: true, + extra: extra.to_owned(), + character_maximum_length: Some(30), + character_set: Some("utf8mb4".to_owned()), + generation_expression: Some(expression.to_owned()), + ..MySqlColumnRow::default() + }; + let generated = |definition: ColumnDefinition| { + definition + .attributes + .generated + .map(|generated| (generated.expression.as_sql().to_owned(), generated.storage)) + }; + + let mysql = column_definition( + &row("VIRTUAL GENERATED", r"concat(`a`,_utf8mb4\'x\')"), + MySqlFlavour::MySql, + Vec::new(), + ); + let mariadb = column_definition( + &row("STORED GENERATED, INVISIBLE", "concat(`a`,'x')"), + MySqlFlavour::MariaDb, + Vec::new(), + ); + + assert_eq!( + generated(mysql), + Some(("concat(`a`,_utf8mb4'x')".to_owned(), GeneratedStorage::Virtual)) + ); + assert_eq!(mariadb.attributes.visibility, ColumnVisibility::Invisible); + assert!(mariadb.attributes.unmodeled.is_empty()); + assert_eq!( + generated(mariadb), + Some(("concat(`a`,'x')".to_owned(), GeneratedStorage::Stored)) + ); + } } diff --git a/linux/crates/drivers/mysql/src/catalog/escaped_expression.rs b/linux/crates/drivers/mysql/src/catalog/escaped_expression.rs new file mode 100644 index 0000000000..e5dddf90a4 --- /dev/null +++ b/linux/crates/drivers/mysql/src/catalog/escaped_expression.rs @@ -0,0 +1,44 @@ +/// An expression as MySQL lists it in `information_schema.COLUMNS`, +/// turned back into the SQL SHOW CREATE TABLE prints. +/// +/// MySQL 8 escapes a generation expression and an expression default +/// for information_schema, putting a backslash before every quote and +/// backslash and writing NUL and Ctrl-Z as `\0` and `\Z`. Spliced as +/// listed, `DEFAULT (_utf8mb4'x')` would come back as `_utf8mb4\'x\'`, +/// which no statement parses. MariaDB lists the SQL itself. +pub(crate) fn unescape_expression(listed: &str) -> String { + let mut sql = String::with_capacity(listed.len()); + let mut characters = listed.chars(); + while let Some(character) = characters.next() { + if character != '\\' { + sql.push(character); + continue; + } + match characters.next() { + Some('0') => sql.push('\0'), + Some('Z') => sql.push('\u{1a}'), + Some(escaped) => sql.push(escaped), + None => sql.push('\\'), + } + } + sql +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_listed_expression_reads_back_as_the_sql_show_create_prints() { + assert_eq!( + unescape_expression(r#"_utf8mb4\'{"a":"it\\\'s"}\'"#), + r#"_utf8mb4'{"a":"it\'s"}'"# + ); + assert_eq!( + unescape_expression(r"concat(_utf8mb4\'a\',_utf8mb4\'b\\nc\\\\d\')"), + r"concat(_utf8mb4'a',_utf8mb4'b\nc\\d')" + ); + assert_eq!(unescape_expression("uuid()"), "uuid()"); + assert_eq!(unescape_expression(r"(`b` * 2)"), "(`b` * 2)"); + } +} diff --git a/linux/crates/drivers/mysql/src/catalog/mysql_extra.rs b/linux/crates/drivers/mysql/src/catalog/mysql_extra.rs index b1a07ada97..baf0684dbf 100644 --- a/linux/crates/drivers/mysql/src/catalog/mysql_extra.rs +++ b/linux/crates/drivers/mysql/src/catalog/mysql_extra.rs @@ -1,5 +1,6 @@ //! The EXTRA column of `information_schema.COLUMNS`, which packs several -//! unrelated attributes into one space-separated string. +//! unrelated attributes into one string. MySQL separates them with +//! spaces and MariaDB with `, `, as in `VIRTUAL GENERATED, INVISIBLE`. /// How a generated column keeps its value. MariaDB spells STORED as /// PERSISTENT too. @@ -28,7 +29,10 @@ pub(crate) struct MySqlExtra { impl MySqlExtra { pub(crate) fn parse(extra: &str) -> Self { - let words: Vec<&str> = extra.split_whitespace().collect(); + let words: Vec<&str> = extra + .split(|character: char| character.is_whitespace() || character == ',') + .filter(|word| !word.is_empty()) + .collect(); let lower: Vec = words.iter().map(|word| word.to_ascii_lowercase()).collect(); let mut parsed = Self::default(); let mut leftover: Vec<&str> = Vec::new(); @@ -99,6 +103,31 @@ mod tests { Some("current_timestamp()") ); + assert_eq!( + MySqlExtra::parse("on update current_timestamp(3), INVISIBLE"), + MySqlExtra { + on_update: Some("current_timestamp(3)".to_owned()), + invisible: true, + ..MySqlExtra::default() + } + ); + assert_eq!( + MySqlExtra::parse("VIRTUAL GENERATED, INVISIBLE"), + MySqlExtra { + generated: Some(ExtraGenerated::Virtual), + invisible: true, + ..MySqlExtra::default() + } + ); + assert_eq!( + MySqlExtra::parse("auto_increment, INVISIBLE"), + MySqlExtra { + auto_increment: true, + invisible: true, + ..MySqlExtra::default() + } + ); + let versioned = MySqlExtra::parse("WITHOUT SYSTEM VERSIONING"); assert_eq!(versioned.unmodeled, ["WITHOUT SYSTEM VERSIONING"]); assert_eq!(MySqlExtra::parse(""), MySqlExtra::default()); From 52d6a3f8247e234c7102521caadafbe8b9ab3cea Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:44:19 +0700 Subject: [PATCH 129/178] fix(linux): restate every MySQL and MariaDB column attribute on MODIFY --- .../crates/core/src/ddl/column_attributes.rs | 4 + .../core/src/ddl/column_capabilities.rs | 21 +- linux/crates/drivers/mysql/src/catalog.rs | 5 +- .../mysql/src/catalog/table_definition.rs | 49 +++ linux/crates/drivers/mysql/src/ddl.rs | 379 +++++++++--------- .../mysql/src/ddl/mysql_column_definition.rs | 250 ++++++++++++ linux/crates/drivers/mysql/src/metadata.rs | 44 +- .../drivers/mysql/tests/integration/ddl.rs | 325 ++++++++++----- 8 files changed, 770 insertions(+), 307 deletions(-) create mode 100644 linux/crates/drivers/mysql/src/ddl/mysql_column_definition.rs diff --git a/linux/crates/core/src/ddl/column_attributes.rs b/linux/crates/core/src/ddl/column_attributes.rs index 19575603fa..9230c2bcf5 100644 --- a/linux/crates/core/src/ddl/column_attributes.rs +++ b/linux/crates/core/src/ddl/column_attributes.rs @@ -24,6 +24,10 @@ pub struct ColumnAttributes { pub spatial_reference: Option, /// SQL Server `SPARSE`. pub sparse: bool, + /// A CHECK constraint MariaDB keeps on the column itself. MODIFY + /// drops it unless the definition restates it; every other engine + /// keeps a CHECK on the table, where a redefinition leaves it alone. + pub check: Option, pub identity: IdentityStyle, /// The SQL Server constraint that holds the default. A type change /// has to drop it by name first and add it back afterwards. diff --git a/linux/crates/core/src/ddl/column_capabilities.rs b/linux/crates/core/src/ddl/column_capabilities.rs index a9b4cd79a9..5b63eac46f 100644 --- a/linux/crates/core/src/ddl/column_capabilities.rs +++ b/linux/crates/core/src/ddl/column_capabilities.rs @@ -50,7 +50,7 @@ impl ColumnCapabilities { alter_default: engine(capabilities.alter_column_default) .and(|| not_generated(column)) .and(not_numbered) - .and(|| modeled(column)), + .and(|| Self::modeled(column)), alter_primary_key: primary_key_permission(capabilities, table), alter_auto_increment: engine(capabilities.alter_auto_increment).and(restatement), alter_comment: engine(capabilities.column_comments), @@ -61,7 +61,17 @@ impl ColumnCapabilities { /// one would lose its expression, and one with an attribute this /// build does not model would lose that. pub fn restatement(column: &ColumnDefinition) -> Permission { - not_generated(column).and(|| modeled(column)) + not_generated(column).and(|| Self::modeled(column)) + } + + /// Whether the catalogue reported only attributes this build can + /// write again. An engine that restates a generated column's + /// expression checks this rather than `restatement`. + pub fn modeled(column: &ColumnDefinition) -> Permission { + match column.attributes.unmodeled.first() { + Some(attribute) => Permission::Refused(RefusalReason::UnmodeledAttribute(attribute.name.clone())), + None => Permission::Allowed, + } } } @@ -72,13 +82,6 @@ fn not_generated(column: &ColumnDefinition) -> Permission { ) } -fn modeled(column: &ColumnDefinition) -> Permission { - match column.attributes.unmodeled.first() { - Some(attribute) => Permission::Refused(RefusalReason::UnmodeledAttribute(attribute.name.clone())), - None => Permission::Allowed, - } -} - /// Whether the table's primary key can move. A foreign key elsewhere /// that points at the key holds its constraint in place: PostgreSQL /// needs CASCADE to drop it, and MySQL, SQL Server and SQLite refuse diff --git a/linux/crates/drivers/mysql/src/catalog.rs b/linux/crates/drivers/mysql/src/catalog.rs index ef9a1400e2..3309cdda09 100644 --- a/linux/crates/drivers/mysql/src/catalog.rs +++ b/linux/crates/drivers/mysql/src/catalog.rs @@ -12,7 +12,10 @@ mod table_definition; pub(crate) use catalog_cells::CatalogCells; pub(crate) use mysql_column_row::{MySqlColumnRow, columns_sql}; pub(crate) use mysql_extra::{ExtraGenerated, MySqlExtra}; -pub(crate) use table_definition::{COLUMN_ENGINE_ATTRIBUTES_SQL, INBOUND_FOREIGN_KEYS_SQL, inbound_foreign_keys}; +pub(crate) use table_definition::{ + COLUMN_ENGINE_ATTRIBUTES_SQL, INBOUND_FOREIGN_KEYS_SQL, MARIADB_COLUMN_CHECKS_SQL, MARIADB_GEOMETRY_SRIDS_SQL, + column_checks, geometry_srids, inbound_foreign_keys, +}; use sqlx::mysql::{MySql, MySqlColumn}; use sqlx::{Column as _, Type, TypeInfo as _}; diff --git a/linux/crates/drivers/mysql/src/catalog/table_definition.rs b/linux/crates/drivers/mysql/src/catalog/table_definition.rs index 6f26d59b53..93fa4f257a 100644 --- a/linux/crates/drivers/mysql/src/catalog/table_definition.rs +++ b/linux/crates/drivers/mysql/src/catalog/table_definition.rs @@ -19,6 +19,42 @@ SELECT COLUMN_NAME, ENGINE_ATTRIBUTE, SECONDARY_ENGINE_ATTRIBUTE FROM information_schema.COLUMNS_EXTENSIONS WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ?"; +/// MariaDB's SRID of each geometry column. COLUMNS and SHOW CREATE TABLE +/// leave it out, and a MODIFY that does not restate it resets it to 0. +/// The F_ names are the ones the server looks a table up by. +pub(crate) const MARIADB_GEOMETRY_SRIDS_SQL: &str = "\ +SELECT G_GEOMETRY_COLUMN, SRID +FROM information_schema.GEOMETRY_COLUMNS +WHERE F_TABLE_SCHEMA = COALESCE(?, DATABASE()) AND F_TABLE_NAME = ?"; + +/// MariaDB's CHECK constraints written on a column, each named after its +/// column. A MODIFY that does not restate one drops it, the `json_valid` +/// check MariaDB puts on every JSON column included. +pub(crate) const MARIADB_COLUMN_CHECKS_SQL: &str = "\ +SELECT CONSTRAINT_NAME, CHECK_CLAUSE +FROM information_schema.CHECK_CONSTRAINTS +WHERE CONSTRAINT_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ? AND LEVEL = 'Column'"; + +/// Each geometry column that sets an SRID. MariaDB lists 0 for one that +/// sets none. +pub(crate) fn geometry_srids(rows: &[CatalogCells]) -> Result, DriverError> { + let mut srids = Vec::new(); + for row in rows { + let column = row.required_text(0)?; + if let Some(srid) = row.number(1)?.filter(|srid| *srid != 0) { + srids.push((column, srid)); + } + } + Ok(srids) +} + +/// Each column's own CHECK clause, as SQL. +pub(crate) fn column_checks(rows: &[CatalogCells]) -> Result, DriverError> { + rows.iter() + .map(|row| Ok((row.required_text(0)?, row.required_text(1)?))) + .collect() +} + /// One key per constraint, its columns in order. pub(crate) fn inbound_foreign_keys(rows: &[CatalogCells]) -> Result, DriverError> { let mut keys: Vec = Vec::new(); @@ -60,6 +96,19 @@ mod tests { ) } + #[test] + fn only_a_geometry_column_that_sets_an_srid_keeps_one() { + let rows = [ + CatalogCells::new( + "GEOMETRY_COLUMNS", + vec![Value::Text("shape".to_owned()), Value::UInt(4326)], + ), + CatalogCells::new("GEOMETRY_COLUMNS", vec![Value::Text("area".to_owned()), Value::Int(0)]), + ]; + + assert_eq!(geometry_srids(&rows).expect("SRIDs"), [("shape".to_owned(), 4326)]); + } + #[test] fn a_composite_key_collects_its_columns_in_order() { let rows = [ diff --git a/linux/crates/drivers/mysql/src/ddl.rs b/linux/crates/drivers/mysql/src/ddl.rs index 28396dd6b6..09c29188fc 100644 --- a/linux/crates/drivers/mysql/src/ddl.rs +++ b/linux/crates/drivers/mysql/src/ddl.rs @@ -1,16 +1,15 @@ -use tablepro_core::column::{ColumnDefault, ColumnKind}; -use tablepro_core::ddl::ddl_sql::{ - column_list, create_index_sql, default_sql, foreign_key_sql, index_keys_sql, parenthesized, -}; +mod mysql_column_definition; + +use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, foreign_key_sql, index_keys_sql}; use tablepro_core::ddl::{ - BuildDdlError, ColumnAttributes, ColumnCapabilities, ColumnDefinition, ColumnVisibility, DdlCapabilities, - DdlDialect, DdlPlan, DraftColumn, DraftType, Permission, ReferentialAction, RefusalReason, TableChanges, - TableDefinition, TableDraft, + BuildDdlError, ChangedAttributes, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlPlan, + Permission, ReferentialAction, RefusalReason, TableChanges, TableDefinition, TableDraft, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; use crate::dialect::MySqlDialect; +use mysql_column_definition::{nullability_permission, render, restatement}; const fn capabilities(expression_index_keys: bool) -> DdlCapabilities { DdlCapabilities { @@ -85,14 +84,23 @@ impl DdlDialect for MySqlDialect { self } - /// A comment is part of the definition MODIFY restates, so it takes - /// what a restatement takes. A MODIFY restates the type too, so a + /// Every change but a rename writes the column again whole, its + /// comment included, so each takes what the restatement takes. A + /// generated column is restated with its expression, but takes no + /// default and no AUTO_INCREMENT. A MODIFY restates the type too, so a /// column whose type the catalogue did not spell cannot take one. fn column_capabilities(&self, table: &TableDefinition, column: &ColumnDefinition) -> ColumnCapabilities { - let mut permissions = ColumnCapabilities::standard(DdlDialect::capabilities(self), table, column); - permissions.alter_comment = permissions - .alter_comment - .and(|| ColumnCapabilities::restatement(column)); + let capabilities = DdlDialect::capabilities(self); + let engine = |allowed: bool| Permission::unless(allowed, RefusalReason::EngineUnsupported); + let restated = || restatement(column); + let mut permissions = ColumnCapabilities::standard(capabilities, table, column); + permissions.alter_type = engine(capabilities.alter_column_type).and(restated); + permissions.alter_nullability = engine(capabilities.alter_column_nullability) + .and(restated) + .and(|| nullability_permission(self, column)); + permissions.alter_default = permissions.alter_default.and(restated); + permissions.alter_auto_increment = permissions.alter_auto_increment.and(restated); + permissions.alter_comment = engine(capabilities.column_comments).and(restated); if column.info.column_type.catalog.is_none() { let unreadable = || Permission::Refused(RefusalReason::UnreadableDefinition); permissions.alter_nullability = permissions.alter_nullability.and(unreadable); @@ -121,7 +129,7 @@ impl DdlDialect for MySqlDialect { let mut definitions = draft .columns .iter() - .map(|column| self.column_definition(column)) + .map(|column| render(self, column)) .collect::, _>>()?; let key: Vec = draft.primary_key().into_iter().map(str::to_owned).collect(); if !key.is_empty() { @@ -190,7 +198,7 @@ impl DdlDialect for MySqlDialect { Some(alteration) => actions.push(format!( "CHANGE COLUMN {} {}", self.quote_identifier(&rename.from), - self.column_definition(&alteration.draft)? + render(self, &alteration.draft)? )), None => actions.push(format!( "RENAME COLUMN {} TO {}", @@ -202,11 +210,11 @@ impl DdlDialect for MySqlDialect { for alteration in &changes.altered_columns { let renamed = alteration.original.name() != alteration.draft.name; if !renamed && restates(alteration.changed) { - actions.push(format!("MODIFY COLUMN {}", self.column_definition(&alteration.draft)?)); + actions.push(format!("MODIFY COLUMN {}", render(self, &alteration.draft)?)); } } for column in &changes.added_columns { - actions.push(format!("ADD COLUMN {}", self.column_definition(column)?)); + actions.push(format!("ADD COLUMN {}", render(self, column)?)); } if let Some(change) = changes.primary_key.as_ref().filter(|change| !change.to.is_empty()) { actions.push(format!("ADD PRIMARY KEY ({})", column_list(self, &change.to))); @@ -247,137 +255,18 @@ impl DdlDialect for MySqlDialect { /// Whether an alteration needs the column's definition restated. A /// primary-key flag alone is the table's key clause. -fn restates(changed: tablepro_core::ddl::ChangedAttributes) -> bool { +fn restates(changed: ChangedAttributes) -> bool { changed.column_type || changed.nullability || changed.default || changed.auto_increment || changed.comment } -impl MySqlDialect { - /// The whole definition MODIFY or CHANGE replaces the old one with: - /// an attribute it leaves out is gone, so every one the column had - /// that still fits its type is written again. - fn column_definition(&self, column: &DraftColumn) -> Result { - let resolved = column.column_type.resolve(self, &column.name)?; - let kind = &resolved.column_type.kind; - let added = ColumnAttributes::default(); - let kept = column.original.as_ref().map_or(&added, |original| &original.attributes); - let mut parts = vec![self.quote_identifier(&column.name), resolved.sql.clone()]; - // The catalogue names a character set only for a column that - // holds text, and a type the user typed may not. - let textual = matches!(column.column_type, DraftType::Catalog(_)) - || matches!(kind, ColumnKind::Text { .. } | ColumnKind::Enum { .. }); - if textual { - if let Some(character_set) = &kept.character_set { - parts.push(format!("CHARACTER SET {}", self.quote_identifier(character_set))); - } - if let Some(collation) = &kept.collation { - parts.push(format!("COLLATE {}", self.quote_identifier(&collation.name))); - } - } - parts.push(if column.nullable { "NULL" } else { "NOT NULL" }.to_owned()); - if !column.auto_increment - && let Some(default) = default_sql(self, &column.name, &column.default, &resolved.column_type)? - { - parts.push(format!( - "DEFAULT {}", - default_clause(&column.default, &default, &resolved.sql) - )); - } - if let Some(expression) = kept - .on_update - .as_ref() - .filter(|_| matches!(kind, ColumnKind::Timestamp { .. })) - { - parts.push(format!("ON UPDATE {}", expression.as_sql())); - } - if column.auto_increment { - parts.push("AUTO_INCREMENT".to_owned()); - } - if kept.visibility == ColumnVisibility::Invisible { - parts.push("INVISIBLE".to_owned()); - } - if let Some(srid) = kept.spatial_reference.filter(|_| matches!(kind, ColumnKind::Spatial)) { - parts.push(format!("SRID {srid}")); - } - if let Some(comment) = column.comment_text() { - parts.push(format!("COMMENT {}", comment_literal(comment))); - } - Ok(parts.join(" ")) - } -} - -/// MySQL takes an expression default only in parentheses, apart from the -/// CURRENT_TIMESTAMP family, and takes a literal on a BLOB, TEXT, JSON or -/// spatial column only as an expression. -fn default_clause(default: &ColumnDefault, sql: &str, declaration: &str) -> String { - match default { - ColumnDefault::Expression(_) if is_current_timestamp(sql) => sql.to_owned(), - ColumnDefault::Expression(_) => parenthesized(sql), - ColumnDefault::Literal(_) if needs_expression_default(declaration) => format!("({sql})"), - _ => sql.to_owned(), - } -} - -fn is_current_timestamp(sql: &str) -> bool { - let compact: String = sql - .chars() - .filter(|character| !character.is_whitespace()) - .collect::() - .to_ascii_lowercase(); - let name = compact.split('(').next().unwrap_or_default(); - let arguments = compact.get(name.len()..).unwrap_or_default(); - let precision_only = arguments.is_empty() - || (arguments.starts_with('(') - && arguments.ends_with(')') - && arguments - .get(1..arguments.len() - 1) - .is_some_and(|digits| digits.chars().all(|character| character.is_ascii_digit()))); - matches!(name, "current_timestamp" | "now" | "localtime" | "localtimestamp") && precision_only -} - -fn needs_expression_default(declaration: &str) -> bool { - let name = declaration - .trim() - .split(|character: char| character == '(' || character.is_whitespace()) - .next() - .unwrap_or_default() - .to_ascii_lowercase(); - matches!( - name.as_str(), - "tinytext" - | "text" - | "mediumtext" - | "longtext" - | "tinyblob" - | "blob" - | "mediumblob" - | "longblob" - | "json" - | "geometry" - | "point" - | "linestring" - | "polygon" - | "multipoint" - | "multilinestring" - | "multipolygon" - | "geometrycollection" - ) -} - -/// COMMENT takes a plain string only, with no introducer or hex form. -/// Workspace sessions run with backslash escapes on, so doubling both the -/// quote and the backslash reads back as the text. -fn comment_literal(text: &str) -> String { - format!("'{}'", text.replace('\\', "\\\\").replace('\'', "''")) -} - #[cfg(test)] mod tests { use tablepro_core::ForeignKeyInfo; use tablepro_core::ServerInfo; - use tablepro_core::column::{ColumnInfo, SqlExpression}; + use tablepro_core::column::{ColumnDefault, ColumnInfo, SqlExpression}; use tablepro_core::ddl::{ - CollationName, DdlOperation, DropTableOptions, GeneratedColumn, GeneratedStorage, InboundForeignKey, - PrimaryKeyConstraint, UnmodeledAttribute, diff_table, + CollationName, ColumnAttributes, ColumnVisibility, DdlOperation, DraftColumn, DraftType, DropTableOptions, + GeneratedColumn, GeneratedStorage, InboundForeignKey, PrimaryKeyConstraint, UnmodeledAttribute, diff_table, }; use tablepro_core::meta::{IndexInfo, IndexKey}; use tablepro_core::value::Value; @@ -389,6 +278,10 @@ mod tests { MySqlDialect::new(MySqlFlavour::MySql, &ServerInfo::new("MySQL", "8.4.3", 8, 4, 3)) } + fn mariadb() -> MySqlDialect { + MySqlDialect::new(MySqlFlavour::MariaDb, &ServerInfo::new("MariaDB", "11.4.5", 11, 4, 5)) + } + fn column(name: &str, declaration: &str, primary_key: bool) -> ColumnDefinition { ColumnDefinition::new( ColumnInfo { @@ -437,9 +330,17 @@ mod tests { } fn alter(original: &TableDefinition, edit: impl FnOnce(&mut TableDraft)) -> Result { + alter_on(&dialect(), original, edit) + } + + fn alter_on( + dialect: &MySqlDialect, + original: &TableDefinition, + edit: impl FnOnce(&mut TableDraft), + ) -> Result { let mut draft = TableDraft::from_definition(original); edit(&mut draft); - dialect().plan_alter_table(original, &diff_table(original, &draft)) + dialect.plan_alter_table(original, &diff_table(original, &draft)) } fn sql(plan: Result) -> Vec { @@ -707,11 +608,18 @@ mod tests { ); } + fn refused(plan: &Result, operation: DdlOperation, reason: &RefusalReason) -> bool { + matches!(plan, Err(BuildDdlError::Refused { operation: refused, reason: why, .. }) if *refused == operation && why == reason) + } + #[test] - fn a_restatement_keeps_charset_collation_on_update_invisible_and_srid() { - let plan = alter(&attributed(), |draft| { + fn mysql_render_restates_comment_charset_collation_on_update_invisible_srid() { + let mut table = attributed(); + table.columns[3].info.comment = Some("kept \\ note".to_owned()); + + let plan = alter(&table, |draft| { edit_column(draft, "code", |column| column.nullable = false); - edit_column(draft, "stamp", |column| column.comment = Some("touched".to_owned())); + edit_column(draft, "stamp", |column| column.comment = Some("it's \\ x".to_owned())); edit_column(draft, "shape", |column| column.nullable = false); }); @@ -719,14 +627,14 @@ mod tests { sql(plan), [ "ALTER TABLE `app`.`users` MODIFY COLUMN `code` varchar(20) CHARACTER SET `utf8mb4` COLLATE \ - `utf8mb4_bin` NOT NULL, MODIFY COLUMN `stamp` timestamp NULL ON UPDATE CURRENT_TIMESTAMP INVISIBLE \ - COMMENT 'touched', MODIFY COLUMN `shape` point NOT NULL SRID 4326" + `utf8mb4_bin` NOT NULL COMMENT 'kept \\\\ note', MODIFY COLUMN `stamp` timestamp NULL ON UPDATE \ + CURRENT_TIMESTAMP INVISIBLE COMMENT 'it''s \\\\ x', MODIFY COLUMN `shape` point NOT NULL SRID 4326" ] ); } #[test] - fn a_new_type_drops_the_attributes_it_cannot_hold() { + fn mysql_type_change_to_int_drops_charset_and_collation() { let plan = alter(&attributed(), |draft| { edit_column(draft, "code", |column| { column.column_type = DraftType::Typed("int".to_owned()) @@ -734,11 +642,17 @@ mod tests { edit_column(draft, "stamp", |column| { column.column_type = DraftType::Typed("date".to_owned()) }); + edit_column(draft, "shape", |column| { + column.column_type = DraftType::Typed("polygon".to_owned()) + }); }); assert_eq!( sql(plan), - ["ALTER TABLE `app`.`users` MODIFY COLUMN `code` int NULL, MODIFY COLUMN `stamp` date NULL INVISIBLE"] + [ + "ALTER TABLE `app`.`users` MODIFY COLUMN `code` int NULL, MODIFY COLUMN `stamp` date NULL INVISIBLE, \ + MODIFY COLUMN `shape` polygon NULL SRID 4326" + ] ); // A character set typed with the type would clash with the one // kept from the catalogue, so the type parser has to refuse it. @@ -747,75 +661,152 @@ mod tests { } #[test] - fn a_comment_is_refused_where_a_restatement_is() { + fn mysql_unmodeled_attribute_refuses_alteration() { let mut table = users(); table.columns.extend([ - with_attributes("total", "int", |attributes| { - attributes.generated = Some(GeneratedColumn { - expression: SqlExpression::from_catalog_text("`id` * 2"), - storage: GeneratedStorage::Stored, - }); - }), with_attributes("note", "varchar(20)", |attributes| { attributes.unmodeled = vec![UnmodeledAttribute::new("WITHOUT SYSTEM VERSIONING")]; }), + with_attributes("label", "varchar(20)", |attributes| { + attributes.collation = Some(CollationName::new(None, "odd-name")); + }), ]); - table.columns[3].info.is_generated = true; - - let generated = dialect().column_capabilities(&table, &table.columns[3]); - let unmodeled = dialect().column_capabilities(&table, &table.columns[4]); - let commented = alter(&table, |draft| { - edit_column(draft, "total", |column| column.comment = Some("x".to_owned())); - }); + let unmodeled = RefusalReason::UnmodeledAttribute("WITHOUT SYSTEM VERSIONING".to_owned()); + let odd = RefusalReason::UnmodeledAttribute("COLLATE odd-name".to_owned()); + + for (column, reason) in [(&table.columns[3], &unmodeled), (&table.columns[4], &odd)] { + let permissions = dialect().column_capabilities(&table, column); + for permission in [ + &permissions.alter_type, + &permissions.alter_nullability, + &permissions.alter_default, + &permissions.alter_auto_increment, + &permissions.alter_comment, + ] { + assert_eq!(permission, &Permission::Refused(reason.clone()), "{}", column.name()); + } + assert_eq!(permissions.rename, Permission::Allowed); + } let renamed_and_commented = alter(&table, |draft| { edit_column(draft, "note", |column| { column.name = "remark".to_owned(); column.comment = Some("x".to_owned()); }); }); + let renamed = alter(&table, |draft| { + edit_column(draft, "label", |column| column.name = "tag".to_owned()) + }); + + assert!( + refused(&renamed_and_commented, DdlOperation::SetColumnComment, &unmodeled), + "{renamed_and_commented:?}" + ); + assert_eq!( + sql(renamed), + ["ALTER TABLE `app`.`users` RENAME COLUMN `label` TO `tag`"] + ); + } + + fn generated(table: &mut TableDefinition, name: &str, expression: Option<&str>) { + let mut column = with_attributes(name, "int", |attributes| { + attributes.generated = expression.map(|expression| GeneratedColumn { + expression: SqlExpression::from_catalog_text(expression), + storage: GeneratedStorage::Stored, + }); + }); + column.info.is_generated = true; + table.columns.push(column); + } + #[test] + fn a_generated_column_is_restated_with_its_expression() { + let mut table = users(); + generated(&mut table, "total", Some("(`id` * 2)")); + generated(&mut table, "unread", None); + + let total = dialect().column_capabilities(&table, &table.columns[3]); + let unread = dialect().column_capabilities(&table, &table.columns[4]); + let plan = alter(&table, |draft| { + edit_column(draft, "total", |column| { + column.column_type = DraftType::Typed("bigint".to_owned()); + column.nullable = false; + column.comment = Some("doubled".to_owned()); + }); + }); + + assert_eq!(total.alter_type, Permission::Allowed); + assert_eq!(total.alter_nullability, Permission::Allowed); + assert_eq!(total.alter_comment, Permission::Allowed); + assert_eq!(total.alter_default, Permission::Refused(RefusalReason::GeneratedColumn)); assert_eq!( - generated.alter_comment, + total.alter_auto_increment, Permission::Refused(RefusalReason::GeneratedColumn) ); assert_eq!( - unmodeled.alter_comment, - Permission::Refused(RefusalReason::UnmodeledAttribute( - "WITHOUT SYSTEM VERSIONING".to_owned() - )) + unread.alter_comment, + Permission::Refused(RefusalReason::GeneratedColumn) ); - assert_eq!(generated.rename, Permission::Allowed); - assert!( - matches!( - commented, - Err(BuildDdlError::Refused { - operation: DdlOperation::SetColumnComment, - reason: RefusalReason::GeneratedColumn, - .. - }) - ), - "{commented:?}" + assert_eq!( + sql(plan), + [ + "ALTER TABLE `app`.`users` MODIFY COLUMN `total` bigint GENERATED ALWAYS AS (`id` * 2) STORED NOT NULL \ + COMMENT 'doubled'" + ] ); - assert!( - matches!( - renamed_and_commented, - Err(BuildDdlError::Refused { - operation: DdlOperation::SetColumnComment, - reason: RefusalReason::UnmodeledAttribute(_), - .. - }) - ), - "{renamed_and_commented:?}" + } + + #[test] + fn an_expression_default_is_restated_in_parentheses() { + let mut table = users(); + table.columns.push(with_attributes("token", "char(36)", |_| {})); + table.columns[3].info.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("uuid()")); + + let plan = alter(&table, |draft| { + edit_column(draft, "token", |column| column.nullable = false) + }); + + assert_eq!( + sql(plan), + ["ALTER TABLE `app`.`users` MODIFY COLUMN `token` char(36) NOT NULL DEFAULT (uuid())"] ); } #[test] - fn only_the_current_timestamp_family_goes_bare() { - assert!(is_current_timestamp("CURRENT_TIMESTAMP")); - assert!(is_current_timestamp("current_timestamp(6)")); - assert!(is_current_timestamp("now()")); - assert!(!is_current_timestamp("now() + interval 1 day")); - assert!(!is_current_timestamp("uuid()")); + fn mariadb_restates_its_srid_column_check_and_generated_columns() { + let mut table = users(); + table.columns.extend([ + with_attributes("shape", "point", |attributes| attributes.spatial_reference = Some(4326)), + with_attributes("doc", "longtext", |attributes| { + attributes.character_set = Some("utf8mb4".to_owned()); + attributes.collation = Some(CollationName::new(None, "utf8mb4_bin")); + attributes.check = Some(SqlExpression::from_catalog_text("json_valid(`doc`)")); + attributes.visibility = ColumnVisibility::Invisible; + }), + ]); + generated(&mut table, "total", Some("`id` * 2")); + table.columns[5].attributes.visibility = ColumnVisibility::Invisible; + + let total = mariadb().column_capabilities(&table, &table.columns[5]); + let plan = alter_on(&mariadb(), &table, |draft| { + edit_column(draft, "shape", |column| column.nullable = false); + edit_column(draft, "doc", |column| column.comment = Some("x".to_owned())); + edit_column(draft, "total", |column| column.comment = Some("y".to_owned())); + }); + + assert_eq!( + total.alter_nullability, + Permission::Refused(RefusalReason::GeneratedColumn) + ); + assert_eq!(total.alter_comment, Permission::Allowed); + assert_eq!( + sql(plan), + [ + "ALTER TABLE `app`.`users` MODIFY COLUMN `shape` point REF_SYSTEM_ID=4326 NOT NULL, MODIFY COLUMN \ + `doc` longtext CHARACTER SET `utf8mb4` COLLATE `utf8mb4_bin` NULL INVISIBLE COMMENT 'x' CHECK \ + (json_valid(`doc`)), MODIFY COLUMN `total` int GENERATED ALWAYS AS (`id` * 2) STORED INVISIBLE \ + COMMENT 'y'" + ] + ); } #[test] diff --git a/linux/crates/drivers/mysql/src/ddl/mysql_column_definition.rs b/linux/crates/drivers/mysql/src/ddl/mysql_column_definition.rs new file mode 100644 index 0000000000..7512e73dbc --- /dev/null +++ b/linux/crates/drivers/mysql/src/ddl/mysql_column_definition.rs @@ -0,0 +1,250 @@ +//! A MySQL or MariaDB column definition, as CREATE TABLE, ADD COLUMN, +//! MODIFY COLUMN and CHANGE COLUMN write it. + +use tablepro_core::column::{ColumnDefault, ColumnKind}; +use tablepro_core::ddl::ddl_sql::{default_sql, parenthesized}; +use tablepro_core::ddl::{ + BuildDdlError, ColumnAttributes, ColumnCapabilities, ColumnDefinition, ColumnVisibility, DraftColumn, DraftType, + GeneratedStorage, Permission, RefusalReason, +}; +use tablepro_core::dialect::SqlDialect; + +use crate::dialect::MySqlDialect; +use crate::flavour::MySqlFlavour; + +/// ` [REF_SYSTEM_ID=n] [CHARACTER SET cs] [COLLATE coll] +/// [GENERATED ALWAYS AS (expr) VIRTUAL|STORED] [NULL|NOT NULL] +/// [DEFAULT ..] [ON UPDATE expr] [AUTO_INCREMENT] [INVISIBLE] [SRID n] +/// [COMMENT '..'] [CHECK (..)]`. +/// +/// MODIFY and CHANGE replace the old definition whole and carry nothing +/// forward, so every attribute the column had that its new type still +/// holds is written again. A column the user added has none. +pub(crate) fn render(dialect: &MySqlDialect, column: &DraftColumn) -> Result { + let resolved = column.column_type.resolve(dialect, &column.name)?; + let kind = &resolved.column_type.kind; + let added = ColumnAttributes::default(); + let kept = column.original.as_ref().map_or(&added, |original| &original.attributes); + let mariadb = dialect.flavour() == MySqlFlavour::MariaDb; + let srid = kept.spatial_reference.filter(|_| matches!(kind, ColumnKind::Spatial)); + let mut parts = vec![dialect.quote_identifier(&column.name), resolved.sql.clone()]; + // MariaDB reads the SRID only straight after the type. + if mariadb && let Some(srid) = srid { + parts.push(format!("REF_SYSTEM_ID={srid}")); + } + if holds_text(column, kind) { + if let Some(character_set) = &kept.character_set { + parts.push(format!("CHARACTER SET {}", dialect.quote_identifier(character_set))); + } + if let Some(collation) = &kept.collation { + parts.push(format!("COLLATE {}", dialect.quote_identifier(&collation.name))); + } + } + let nullability = if column.nullable { "NULL" } else { "NOT NULL" }; + match &kept.generated { + Some(generated) => { + let storage = match generated.storage { + GeneratedStorage::Virtual => "VIRTUAL", + GeneratedStorage::Stored => "STORED", + }; + parts.push(format!( + "GENERATED ALWAYS AS {} {storage}", + parenthesized(generated.expression.as_sql()) + )); + if !mariadb { + parts.push(nullability.to_owned()); + } + } + None => { + parts.push(nullability.to_owned()); + if !column.auto_increment + && let Some(default) = default_sql(dialect, &column.name, &column.default, &resolved.column_type)? + { + parts.push(format!( + "DEFAULT {}", + default_clause(&column.default, &default, &resolved.sql) + )); + } + if let Some(expression) = kept + .on_update + .as_ref() + .filter(|_| matches!(kind, ColumnKind::Timestamp { .. })) + { + parts.push(format!("ON UPDATE {}", expression.as_sql())); + } + if column.auto_increment { + parts.push("AUTO_INCREMENT".to_owned()); + } + } + } + if kept.visibility == ColumnVisibility::Invisible { + parts.push("INVISIBLE".to_owned()); + } + if !mariadb && let Some(srid) = srid { + parts.push(format!("SRID {srid}")); + } + if let Some(comment) = column.comment_text() { + parts.push(format!("COMMENT {}", comment_literal(comment))); + } + // MariaDB reads a column's CHECK only last. + if let Some(check) = &kept.check { + parts.push(format!("CHECK {}", parenthesized(check.as_sql()))); + } + Ok(parts.join(" ")) +} + +/// Whether `render` writes the column back as it is: every attribute the +/// catalogue reported is modeled, a generated column's expression was +/// read, and each name and expression it splices is one the server +/// itself would print. +pub(crate) fn restatement(column: &ColumnDefinition) -> Permission { + let attributes = &column.attributes; + ColumnCapabilities::modeled(column) + .and(|| { + Permission::unless( + !column.info.is_generated || attributes.generated.is_some(), + RefusalReason::GeneratedColumn, + ) + }) + .and(|| plain_name("CHARACTER SET", attributes.character_set.as_deref())) + .and(|| { + plain_name( + "COLLATE", + attributes.collation.as_ref().map(|collation| collation.name.as_str()), + ) + }) + .and(|| match &attributes.on_update { + Some(expression) if !is_current_timestamp(expression.as_sql()) => Permission::Refused( + RefusalReason::UnmodeledAttribute(format!("ON UPDATE {}", expression.as_sql())), + ), + _ => Permission::Allowed, + }) +} + +/// MariaDB takes no NULL or NOT NULL on a generated column, so its +/// nullability cannot change. +pub(crate) fn nullability_permission(dialect: &MySqlDialect, column: &ColumnDefinition) -> Permission { + let generated = column.info.is_generated || column.attributes.generated.is_some(); + Permission::unless( + dialect.flavour() == MySqlFlavour::MySql || !generated, + RefusalReason::GeneratedColumn, + ) +} + +/// Character set and collation names are words, as the server prints +/// them. Anything else is not a name this build knows how to restate. +fn plain_name(clause: &str, name: Option<&str>) -> Permission { + match name { + Some(name) + if name.is_empty() + || !name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_') => + { + Permission::Refused(RefusalReason::UnmodeledAttribute(format!("{clause} {name}"))) + } + _ => Permission::Allowed, + } +} + +/// The catalogue names a character set only for a column that holds +/// text, and a type the user typed may not. +fn holds_text(column: &DraftColumn, kind: &ColumnKind) -> bool { + matches!(column.column_type, DraftType::Catalog(_)) + || matches!(kind, ColumnKind::Text { .. } | ColumnKind::Enum { .. }) +} + +/// MySQL takes an expression default only in parentheses, apart from the +/// CURRENT_TIMESTAMP family, and takes a literal on a BLOB, TEXT, JSON or +/// spatial column only as an expression. +fn default_clause(default: &ColumnDefault, sql: &str, declaration: &str) -> String { + match default { + ColumnDefault::Expression(_) if is_current_timestamp(sql) => sql.to_owned(), + ColumnDefault::Expression(_) => parenthesized(sql), + ColumnDefault::Literal(_) if needs_expression_default(declaration) => format!("({sql})"), + _ => sql.to_owned(), + } +} + +fn is_current_timestamp(sql: &str) -> bool { + let compact: String = sql + .chars() + .filter(|character| !character.is_whitespace()) + .collect::() + .to_ascii_lowercase(); + let name = compact.split('(').next().unwrap_or_default(); + let arguments = compact.get(name.len()..).unwrap_or_default(); + let precision_only = arguments.is_empty() + || (arguments.starts_with('(') + && arguments.ends_with(')') + && arguments + .get(1..arguments.len() - 1) + .is_some_and(|digits| digits.chars().all(|character| character.is_ascii_digit()))); + matches!(name, "current_timestamp" | "now" | "localtime" | "localtimestamp") && precision_only +} + +fn needs_expression_default(declaration: &str) -> bool { + let name = declaration + .trim() + .split(|character: char| character == '(' || character.is_whitespace()) + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + matches!( + name.as_str(), + "tinytext" + | "text" + | "mediumtext" + | "longtext" + | "tinyblob" + | "blob" + | "mediumblob" + | "longblob" + | "json" + | "geometry" + | "point" + | "linestring" + | "polygon" + | "multipoint" + | "multilinestring" + | "multipolygon" + | "geometrycollection" + ) +} + +/// COMMENT takes a plain string only, with no introducer or hex form. +/// Workspace sessions run with backslash escapes on, so doubling both the +/// quote and the backslash reads back as the text. +fn comment_literal(text: &str) -> String { + format!("'{}'", text.replace('\\', "\\\\").replace('\'', "''")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_current_timestamp_family_goes_bare() { + assert!(is_current_timestamp("CURRENT_TIMESTAMP")); + assert!(is_current_timestamp("current_timestamp(6)")); + assert!(is_current_timestamp("now()")); + assert!(!is_current_timestamp("now() + interval 1 day")); + assert!(!is_current_timestamp("uuid()")); + assert!(!is_current_timestamp("current_timestamp(),")); + } + + #[test] + fn a_comment_doubles_its_quotes_and_backslashes() { + assert_eq!(comment_literal(r"it's \ x"), r"'it''s \\ x'"); + } + + #[test] + fn only_a_word_is_a_name_to_restate() { + assert_eq!(plain_name("COLLATE", Some("utf8mb4_0900_ai_ci")), Permission::Allowed); + assert_eq!(plain_name("COLLATE", None), Permission::Allowed); + assert_eq!( + plain_name("COLLATE", Some("odd-name")), + Permission::Refused(RefusalReason::UnmodeledAttribute("COLLATE odd-name".to_owned())) + ); + } +} diff --git a/linux/crates/drivers/mysql/src/metadata.rs b/linux/crates/drivers/mysql/src/metadata.rs index 523e22bdae..73b7dc4ed9 100644 --- a/linux/crates/drivers/mysql/src/metadata.rs +++ b/linux/crates/drivers/mysql/src/metadata.rs @@ -5,7 +5,7 @@ use futures_util::TryStreamExt; use sqlx::mysql::MySqlConnection; use sqlx::{AssertSqlSafe, Executor as _, Row as _}; -use tablepro_core::column::ColumnInfo; +use tablepro_core::column::{ColumnInfo, SqlExpression}; use tablepro_core::ddl::{ InboundForeignKey, PrimaryKeyConstraint, ReferentialAction, TableDefinition, TableSource, UnmodeledAttribute, }; @@ -17,8 +17,9 @@ use tablepro_core::meta::{ use tablepro_core::{DriverError, ForeignKeyInfo}; use crate::catalog::{ - COLUMN_ENGINE_ATTRIBUTES_SQL, CatalogCells, INBOUND_FOREIGN_KEYS_SQL, MySqlColumnRow, column_definition, - column_info, columns_sql, inbound_foreign_keys, + COLUMN_ENGINE_ATTRIBUTES_SQL, CatalogCells, INBOUND_FOREIGN_KEYS_SQL, MARIADB_COLUMN_CHECKS_SQL, + MARIADB_GEOMETRY_SRIDS_SQL, MySqlColumnRow, column_checks, column_definition, column_info, columns_sql, + geometry_srids, inbound_foreign_keys, }; use crate::decode::decode_row; use crate::dialect::MySqlDialect; @@ -317,9 +318,37 @@ pub(crate) async fn fetch_table_definition( table: &TableRef, ) -> Result { let flavour = dialect.flavour(); - let rows = column_rows(connection, flavour, table).await?; + let mut rows = column_rows(connection, flavour, table).await?; let engine = table_engine::lookup(connection, flavour, table).await?; let attributes = engine_attributes(connection, dialect, table).await; + let checks = match flavour { + MySqlFlavour::MariaDb => { + let binds = table_binds(table); + let geometry = catalog_rows( + connection, + flavour, + MARIADB_GEOMETRY_SRIDS_SQL, + "GEOMETRY_COLUMNS", + &binds, + ) + .await?; + for (column, srid) in geometry_srids(&geometry)? { + if let Some(row) = rows.iter_mut().find(|row| row.name == column) { + row.srs_id = Some(srid); + } + } + let checks = catalog_rows( + connection, + flavour, + MARIADB_COLUMN_CHECKS_SQL, + "CHECK_CONSTRAINTS", + &binds, + ) + .await?; + column_checks(&checks)? + } + MySqlFlavour::MySql => Vec::new(), + }; let columns = rows .iter() .map(|row| { @@ -331,7 +360,12 @@ pub(crate) async fn fetch_table_definition( if engine.is_ndb() { unmodeled.push(UnmodeledAttribute::new("ENGINE=ndbcluster")); } - column_definition(row, flavour, unmodeled) + let mut definition = column_definition(row, flavour, unmodeled); + definition.attributes.check = checks + .iter() + .find(|(column, _)| *column == row.name) + .map(|(_, clause)| SqlExpression::from_catalog_text(clause.as_str())); + definition }) .collect(); let indexes = fetch_indexes(connection, dialect, table).await?; diff --git a/linux/crates/drivers/mysql/tests/integration/ddl.rs b/linux/crates/drivers/mysql/tests/integration/ddl.rs index dbe701d734..5ce32effd2 100644 --- a/linux/crates/drivers/mysql/tests/integration/ddl.rs +++ b/linux/crates/drivers/mysql/tests/integration/ddl.rs @@ -1,12 +1,13 @@ //! Structure changes the MySQL dialect plans, run against both servers. -use tablepro_core::Connection; +use tablepro_core::column::ColumnDefault; use tablepro_core::ddl::{ - BuildDdlError, ColumnVisibility, DdlOperation, RefusalReason, TableDefinition, TableDraft, diff_table, + CollationName, ColumnVisibility, DraftColumn, GeneratedStorage, TableDefinition, TableDraft, diff_table, }; -use tablepro_test_fixtures::MysqlFlavour; +use tablepro_core::{Connection, WriteKind}; +use tablepro_test_fixtures::{MysqlFlavour, MysqlOptions}; -use crate::support::{CALL, FLAVOURS, connect, setup, start, table, within}; +use crate::support::{CALL, apply, connect, scalar, setup, start, start_with, table, text, within}; async fn read(connection: &dyn Connection) -> TableDefinition { connection @@ -15,109 +16,237 @@ async fn read(connection: &dyn Connection) -> TableDefinition { .expect("the definition") } -fn edit( - definition: &TableDefinition, - name: &str, - change: impl FnOnce(&mut tablepro_core::ddl::DraftColumn), -) -> TableDraft { - let mut draft = TableDraft::from_definition(definition); - if let Some(column) = draft.columns.iter_mut().find(|column| column.name == name) { - change(column); +/// Plan the edit against the table as read, run it, and read it again. +async fn change(connection: &dyn Connection, edit: impl Fn(&mut DraftColumn)) -> (TableDefinition, TableDefinition) { + let before = read(connection).await; + let mut draft = TableDraft::from_definition(&before); + draft.columns.iter_mut().for_each(edit); + let dialect = connection.dialect(); + let plan = dialect + .ddl() + .expect("MySQL plans its DDL") + .plan_alter_table(&before, &diff_table(&before, &draft)) + .expect("the change plans"); + connection + .write_batch(&plan.into_write_batch(table("kept")), within(CALL)) + .await + .expect("the change runs"); + let after = read(connection).await; + (before, after) +} + +/// MODIFY replaces the whole definition, so a column comes back as it +/// was in every attribute but the ones the edit changed. +fn assert_kept(before: &TableDefinition, after: &TableDefinition, flavour: MysqlFlavour) { + assert_eq!(before.columns.len(), after.columns.len(), "{flavour:?}"); + for (old, new) in before.columns.iter().zip(&after.columns) { + assert_eq!(old.attributes, new.attributes, "{flavour:?}: {}", old.name()); + assert_eq!( + old.info.column_type, + new.info.column_type, + "{flavour:?}: {}", + old.name() + ); + assert_eq!(old.info.default, new.info.default, "{flavour:?}: {}", old.name()); + assert_eq!( + old.info.is_generated, + new.info.is_generated, + "{flavour:?}: {}", + old.name() + ); } - draft } -/// MODIFY replaces the whole definition, so each attribute the column -/// had is gone unless the plan writes it again. +fn collation(definition: &TableDefinition, column: &str) -> Option { + definition + .column(column) + .and_then(|column| column.attributes.collation.clone()) +} + +/// The server's global sql_mode has NO_BACKSLASH_ESCAPES, which the +/// workspace session takes off, so the doubled backslash in a restated +/// comment reads back as one. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] -async fn a_restated_column_keeps_its_modeled_attributes() { - for flavour in FLAVOURS { - let fixture = start(flavour).await; - let connection = connect(&fixture, false).await; - let spatial = match flavour { - MysqlFlavour::Mysql => ", shape point NOT NULL SRID 4326", - MysqlFlavour::Mariadb => "", - }; - setup( - connection.as_ref(), - &[&format!( - "CREATE TABLE kept (\ - id int PRIMARY KEY, \ - code varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL, \ - changed timestamp(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), \ - hidden int NULL INVISIBLE, \ - base int NULL, \ - doubled int GENERATED ALWAYS AS (base * 2) STORED{spatial})" - )], - ) - .await; - let original = read(connection.as_ref()).await; - let dialect = connection.dialect(); - let ddl = dialect.ddl().expect("MySQL plans its DDL"); +async fn nullability_change_keeps_comment_on_update_collation_invisible() { + let fixture = start_with( + MysqlOptions::new(MysqlFlavour::Mysql) + .allowing_plaintext() + .trusting_function_creators() + .with_server_argument("--sql-mode=NO_BACKSLASH_ESCAPES,STRICT_TRANS_TABLES"), + ) + .await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[r#"CREATE TABLE kept ( + id int PRIMARY KEY, + code varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT 'it''s \\ x', + changed timestamp(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + hidden int NULL DEFAULT 7 INVISIBLE, + base int NULL, + doubled int GENERATED ALWAYS AS (base * 2) STORED, + label varchar(40) GENERATED ALWAYS AS (concat('it''s ', code)) VIRTUAL, + doc json NULL DEFAULT ('{"a": "it''s"}'), + shape point NULL SRID 4326)"#], + ) + .await; - let mut draft = TableDraft::from_definition(&original); - for column in &mut draft.columns { - match column.name.as_str() { - "code" => column.nullable = false, - "changed" | "hidden" | "shape" => column.comment = Some("touched".to_owned()), - _ => {} - } + let (before, after) = change(connection.as_ref(), |column| { + if column.name != "id" { + column.nullable = false; } - let plan = ddl - .plan_alter_table(&original, &diff_table(&original, &draft)) - .expect("the change plans"); - connection - .write_batch(&plan.into_write_batch(table("kept")), within(CALL)) + if column.name == "hidden" { + column.comment = Some(r"it's \ x".to_owned()); + } + }) + .await; + + assert_kept(&before, &after, MysqlFlavour::Mysql); + assert!(after.columns.iter().all(|column| !column.info.nullable)); + let comment = |name: &str| after.column(name).and_then(|column| column.info.comment.clone()); + assert_eq!(comment("code").as_deref(), Some(r"it's \ x")); + assert_eq!(comment("hidden").as_deref(), Some(r"it's \ x")); + assert_eq!(collation(&after, "code"), Some(CollationName::new(None, "utf8mb4_bin"))); + let column = |name: &str| after.column(name).expect("the column"); + assert_eq!( + column("changed") + .attributes + .on_update + .as_ref() + .map(|expression| expression.as_sql().to_owned()), + Some("CURRENT_TIMESTAMP(3)".to_owned()) + ); + assert_eq!(column("hidden").attributes.visibility, ColumnVisibility::Invisible); + assert_eq!(column("shape").attributes.spatial_reference, Some(4326)); + let label = column("label").attributes.generated.as_ref().expect("still generated"); + assert_eq!(label.storage, GeneratedStorage::Virtual); + assert_eq!( + column("doubled") + .attributes + .generated + .as_ref() + .map(|generated| generated.storage), + Some(GeneratedStorage::Stored) + ); + setup( + connection.as_ref(), + &["INSERT INTO kept (id, code, base, shape) VALUES (1, 'a', 2, ST_GeomFromText('POINT(1 1)', 4326))"], + ) + .await; + assert_eq!( + text( + &scalar( + connection.as_ref(), + "SELECT concat(label, '|', doubled, '|', doc->>'$.a') FROM kept" + ) .await - .expect("the change runs"); - let after = read(connection.as_ref()).await; + ), + "it's a|4|it's" + ); + connection.close().await.expect("a close"); +} - let code = after.column("code").expect("code"); - assert!(!code.info.nullable, "{flavour:?}"); - assert_eq!( - code.attributes - .collation - .as_ref() - .map(|collation| collation.name.as_str()), - Some("utf8mb4_bin"), - "{flavour:?}" - ); - let changed = after.column("changed").expect("changed"); - assert_eq!(changed.info.comment.as_deref(), Some("touched"), "{flavour:?}"); - assert!( - changed - .attributes - .on_update - .as_ref() - .is_some_and(|expression| expression - .as_sql() - .to_ascii_lowercase() - .starts_with("current_timestamp")), - "{flavour:?}: {:?}", - changed.attributes.on_update - ); - let hidden = after.column("hidden").expect("hidden"); - assert_eq!(hidden.attributes.visibility, ColumnVisibility::Invisible, "{flavour:?}"); - if flavour == MysqlFlavour::Mysql { - let shape = after.column("shape").expect("shape"); - assert_eq!(shape.attributes.spatial_reference, Some(4326)); - assert_eq!(shape.info.comment.as_deref(), Some("touched")); +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn expression_default_uuid_survives_modify() { + let fixture = start(MysqlFlavour::Mysql).await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &["CREATE TABLE kept (id int PRIMARY KEY, token char(36) NULL DEFAULT (uuid()))"], + ) + .await; + + let (before, after) = change(connection.as_ref(), |column| { + if column.name == "token" { + column.nullable = false; + column.comment = Some("made by the server".to_owned()); } + }) + .await; + setup(connection.as_ref(), &["INSERT INTO kept (id) VALUES (1)"]).await; - let computed = edit(&after, "doubled", |column| column.comment = Some("x".to_owned())); - let refused = ddl.plan_alter_table(&after, &diff_table(&after, &computed)); - assert!( - matches!( - refused, - Err(BuildDdlError::Refused { - operation: DdlOperation::SetColumnComment, - reason: RefusalReason::GeneratedColumn, - .. - }) - ), - "{flavour:?}: {refused:?}" - ); - connection.close().await.expect("a close"); - } + assert_kept(&before, &after, MysqlFlavour::Mysql); + assert!(matches!( + &after.column("token").expect("token").info.default, + ColumnDefault::Expression(expression) if expression.as_sql() == "uuid()" + )); + assert_eq!( + text(&scalar(connection.as_ref(), "SELECT CHAR_LENGTH(token) FROM kept").await), + "36" + ); + connection.close().await.expect("a close"); +} + +/// MariaDB lists its EXTRA words with commas, keeps an SRID and a +/// column's CHECK out of COLUMNS, and takes neither NULL nor NOT NULL on +/// a generated column. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn mariadb_restated_columns_keep_every_attribute() { + let fixture = start(MysqlFlavour::Mariadb).await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[r"CREATE TABLE kept ( + id int PRIMARY KEY, + code varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT 'it''s \\ x', + changed timestamp(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) INVISIBLE, + hidden int NULL DEFAULT 7 INVISIBLE, + base int NULL, + doubled int GENERATED ALWAYS AS (base * 2) STORED, + label varchar(40) GENERATED ALWAYS AS (concat('it''s ', code)) VIRTUAL INVISIBLE, + token char(36) NULL DEFAULT (uuid()), + doc json NULL, + bounded int NULL CHECK (bounded > 0), + shape point REF_SYSTEM_ID=4326 NULL)"], + ) + .await; + + let (before, after) = change(connection.as_ref(), |column| match column.name.as_str() { + "id" => {} + "doubled" | "label" => column.comment = Some(r"it's \ x".to_owned()), + _ => column.nullable = false, + }) + .await; + + assert_kept(&before, &after, MysqlFlavour::Mariadb); + let column = |name: &str| after.column(name).expect("the column"); + assert!(!column("code").info.nullable); + assert_eq!(column("code").info.comment.as_deref(), Some(r"it's \ x")); + assert_eq!(column("label").info.comment.as_deref(), Some(r"it's \ x")); + assert_eq!(collation(&after, "code"), Some(CollationName::new(None, "utf8mb4_bin"))); + assert_eq!(column("changed").attributes.visibility, ColumnVisibility::Invisible); + assert!(column("changed").attributes.on_update.is_some()); + assert_eq!(column("label").attributes.visibility, ColumnVisibility::Invisible); + assert_eq!(column("shape").attributes.spatial_reference, Some(4326)); + assert!( + column("doc") + .attributes + .check + .as_ref() + .is_some_and(|check| check.as_sql().contains("json_valid")) + ); + assert!(column("bounded").attributes.check.is_some()); + setup( + connection.as_ref(), + &["INSERT INTO kept (id, code, base, doc, bounded, shape) VALUES (1, 'a', 2, '{}', 1, ST_PointFromText('POINT(1 1)', 4326))"], + ) + .await; + assert!( + apply( + connection.as_ref(), + WriteKind::RowChanges, + &["INSERT INTO kept (id, code, base, doc, bounded, shape) VALUES (2, 'b', 2, 'not json', 1, ST_PointFromText('POINT(1 1)', 4326))"], + ) + .await + .is_err(), + "the json_valid check is gone" + ); + assert_eq!( + text(&scalar(connection.as_ref(), "SELECT concat(label, '|', doubled) FROM kept").await), + "it's a|4" + ); + connection.close().await.expect("a close"); } From f5478fd67455f9ae2187bce4a2fefd74899c3051 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:44:24 +0700 Subject: [PATCH 130/178] fix(linux): keep a column's collation on a PostgreSQL type change --- linux/crates/drivers/postgres/src/ddl.rs | 46 +++++++++++- .../drivers/postgres/tests/integration/ddl.rs | 70 +++++++++++++++++++ .../postgres/tests/integration/main.rs | 1 + 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 linux/crates/drivers/postgres/tests/integration/ddl.rs diff --git a/linux/crates/drivers/postgres/src/ddl.rs b/linux/crates/drivers/postgres/src/ddl.rs index 16419be5c9..fc7d514ecc 100644 --- a/linux/crates/drivers/postgres/src/ddl.rs +++ b/linux/crates/drivers/postgres/src/ddl.rs @@ -155,8 +155,18 @@ impl DdlDialect for PgDialect { let changed = alteration.changed; if changed.column_type { let resolved = draft.column_type.resolve(self, &draft.name)?; + // A new type takes its own default collation unless the + // change names the one the column had. + let collation = alteration + .original + .attributes + .collation + .as_ref() + .filter(|_| collatable(&resolved.column_type.kind)) + .map(|collation| format!(" COLLATE {}", self.collation(collation))) + .unwrap_or_default(); actions.push(format!( - "ALTER COLUMN {name} TYPE {} USING {name}::{}", + "ALTER COLUMN {name} TYPE {}{collation} USING {name}::{}", resolved.sql, resolved.sql )); } @@ -273,6 +283,15 @@ impl PgDialect { } } +/// Whether a type takes a COLLATE clause: text, and arrays of it. +fn collatable(kind: &ColumnKind) -> bool { + match kind { + ColumnKind::Text { .. } => true, + ColumnKind::Array { element } => collatable(element), + _ => false, + } +} + fn serial_type(column_type: &ColumnType) -> Option<&'static str> { match &column_type.kind { ColumnKind::Integer(IntegerKind { @@ -472,6 +491,31 @@ mod tests { ); } + #[test] + fn pg_type_change_keeps_collation_for_text_only() { + let mut original = users(); + original.columns[1].attributes.collation = Some(CollationName::new(Some("pg_catalog".to_owned()), "C")); + let mut code = column("code", "character varying(10)", false); + code.attributes.collation = Some(CollationName::new(Some("pg_catalog".to_owned()), "C")); + original.columns.push(code); + + let plan = alter(&original, |draft| { + draft.columns[1].column_type = DraftType::Typed("varchar(320)".to_owned()); + draft.columns[3].column_type = DraftType::Typed("integer".to_owned()); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE \"app\".\"users\" ALTER COLUMN \"email\" TYPE varchar(320) COLLATE \ + \"pg_catalog\".\"C\" USING \"email\"::varchar(320), ALTER COLUMN \"code\" TYPE integer USING \ + \"code\"::integer" + ] + ); + assert!(collatable(&dialect().parse_type("text[]").expect("a type").kind)); + assert!(!collatable(&dialect().parse_type("uuid").expect("a type").kind)); + } + #[test] fn renames_run_first_and_the_rest_names_the_new_table() { let plan = alter(&users(), |draft| { diff --git a/linux/crates/drivers/postgres/tests/integration/ddl.rs b/linux/crates/drivers/postgres/tests/integration/ddl.rs new file mode 100644 index 0000000000..f0cdd63e32 --- /dev/null +++ b/linux/crates/drivers/postgres/tests/integration/ddl.rs @@ -0,0 +1,70 @@ +//! Structure changes the PostgreSQL dialect plans, run against a real +//! server. + +use tablepro_core::Connection; +use tablepro_core::ddl::{CollationName, DraftType, TableDefinition, TableDraft, diff_table}; +use tablepro_core::meta::TableRef; + +use crate::support::{CALL, connect, setup, start, within}; + +fn kept() -> TableRef { + TableRef::new(Some("public".to_owned()), "kept") +} + +async fn read(connection: &dyn Connection) -> TableDefinition { + connection + .fetch_table_definition(&kept(), within(CALL)) + .await + .expect("the definition") +} + +/// SET DATA TYPE gives the column the new type's default collation +/// unless the statement names one, so a type change on a text column +/// has to name the one it had. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn type_change_keeps_c_collation() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE kept (id int PRIMARY KEY, code varchar(10) COLLATE \"C\", tags text[] COLLATE \"C\", \ + amount varchar(10) COLLATE \"C\")", + ], + ) + .await; + let before = read(connection.as_ref()).await; + let mut draft = TableDraft::from_definition(&before); + for column in &mut draft.columns { + let retyped = match column.name.as_str() { + "code" => "varchar(40)", + "tags" => "varchar(20)[]", + "amount" => "integer", + _ => continue, + }; + column.column_type = DraftType::Typed(retyped.to_owned()); + } + let dialect = connection.dialect(); + let plan = dialect + .ddl() + .expect("PostgreSQL plans its DDL") + .plan_alter_table(&before, &diff_table(&before, &draft)) + .expect("the change plans"); + + connection + .write_batch(&plan.into_write_batch(kept()), within(CALL)) + .await + .expect("the change runs"); + let after = read(connection.as_ref()).await; + + let c = Some(CollationName::new(Some("pg_catalog".to_owned()), "C")); + let column = |name: &str| after.column(name).expect("the column"); + assert_eq!(column("code").attributes.collation, c); + assert_eq!(column("code").info.column_type.name, "character varying(40)"); + assert_eq!(column("tags").attributes.collation, c); + assert_eq!(column("tags").info.column_type.name, "character varying(20)[]"); + assert_eq!(column("amount").attributes.collation, None); + assert_eq!(column("amount").info.column_type.name, "integer"); + connection.close().await.expect("a close"); +} diff --git a/linux/crates/drivers/postgres/tests/integration/main.rs b/linux/crates/drivers/postgres/tests/integration/main.rs index 18c41628ce..08de5bf75a 100644 --- a/linux/crates/drivers/postgres/tests/integration/main.rs +++ b/linux/crates/drivers/postgres/tests/integration/main.rs @@ -8,6 +8,7 @@ mod cancellation; mod conformance_engine; +mod ddl; mod decode; mod editor_session; mod filter; From 1e4e78684e8b7ecd724383c0a4c29b90863570f5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:44:25 +0700 Subject: [PATCH 131/178] fix(linux): restate collation and SPARSE in SQL Server ALTER COLUMN --- linux/crates/drivers/mssql/src/ddl.rs | 151 ++++++++++++++++-- .../drivers/mssql/tests/integration/ddl.rs | 69 ++++++++ .../drivers/mssql/tests/integration/main.rs | 1 + 3 files changed, 205 insertions(+), 16 deletions(-) create mode 100644 linux/crates/drivers/mssql/tests/integration/ddl.rs diff --git a/linux/crates/drivers/mssql/src/ddl.rs b/linux/crates/drivers/mssql/src/ddl.rs index 52697f66f4..f49f8d1c7c 100644 --- a/linux/crates/drivers/mssql/src/ddl.rs +++ b/linux/crates/drivers/mssql/src/ddl.rs @@ -1,9 +1,9 @@ -use tablepro_core::column::ColumnDefault; +use tablepro_core::column::{ColumnDefault, ColumnKind}; use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, default_sql, foreign_key_sql, parenthesized}; use tablepro_core::ddl::{ - BuildDdlError, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlPlan, DraftColumn, - GeneratedStorage, IdentityStyle, Permission, ReferentialAction, RefusalReason, TableChanges, TableDefinition, - TableDraft, + BuildDdlError, ColumnAttributes, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlOperation, + DdlPlan, DraftColumn, DraftType, GeneratedStorage, IdentityStyle, Permission, ReferentialAction, RefusalReason, + ResolvedType, TableChanges, TableDefinition, TableDraft, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; @@ -75,10 +75,14 @@ impl DdlDialect for MssqlDialect { self } - /// ALTER COLUMN restates the type with the nullability, so a column - /// whose type the catalogue did not spell cannot take one. + /// ALTER COLUMN restates the type with the collation and the + /// nullability, so a column whose type the catalogue did not spell, or + /// whose collation is not a name T-SQL takes bare, cannot take one. fn column_capabilities(&self, table: &TableDefinition, column: &ColumnDefinition) -> ColumnCapabilities { let mut permissions = ColumnCapabilities::standard(&CAPABILITIES, table, column); + let collation = || collation_permission(&column.attributes); + permissions.alter_type = permissions.alter_type.and(collation); + permissions.alter_nullability = permissions.alter_nullability.and(collation); if column.info.column_type.catalog.is_none() { permissions.alter_nullability = permissions .alter_nullability @@ -180,12 +184,23 @@ impl DdlDialect for MssqlDialect { if changed.default { statements.extend(self.drop_default(&table, &alteration.original)); } - // T-SQL reads an omitted NULL or NOT NULL as NULL, so a type - // change restates the nullability. + // ALTER COLUMN carries nothing forward: an omitted collation is + // the database's, an omitted SPARSE is gone, and an omitted + // NULL or NOT NULL is NULL. So every one is restated. if changed.column_type || changed.nullability { let resolved = draft.column_type.resolve(self, &draft.name)?; - let nullability = if draft.nullable { "NULL" } else { "NOT NULL" }; - statements.push(alter(format!("ALTER COLUMN {name} {} {nullability}", resolved.sql))); + let attributes = &alteration.original.attributes; + let operation = match changed.column_type { + true => DdlOperation::AlterColumnType, + false => DdlOperation::AlterColumnNullability, + }; + let mut parts = vec![format!("ALTER COLUMN {name}"), resolved.sql.clone()]; + parts.extend(collate_clause(draft, &resolved, attributes, operation)?); + if attributes.sparse { + parts.push("SPARSE".to_owned()); + } + parts.push(if draft.nullable { "NULL" } else { "NOT NULL" }.to_owned()); + statements.push(alter(parts.join(" "))); } if changed.default { let column_type = draft.column_type.column_type(self, &draft.name)?; @@ -236,8 +251,9 @@ impl DdlDialect for MssqlDialect { } impl MssqlDialect { - /// `name type [IDENTITY(1,1)] NULL|NOT NULL [DEFAULT (..)]`, or a - /// computed column read from the catalogue as `name AS (..)`. + /// `name type [COLLATE c] [SPARSE] [IDENTITY(1,1)] NULL|NOT NULL + /// [DEFAULT (..)]`, or a computed column read from the catalogue as + /// `name AS (..)`. fn column_definition(&self, column: &DraftColumn) -> Result { let name = self.quote_identifier(&column.name); let attributes = column.original.as_ref().map(|original| &original.attributes); @@ -253,6 +269,17 @@ impl MssqlDialect { } let resolved = column.column_type.resolve(self, &column.name)?; let mut parts = vec![name, resolved.sql.clone()]; + if let Some(attributes) = attributes { + parts.extend(collate_clause( + column, + &resolved, + attributes, + DdlOperation::CreateTable, + )?); + if attributes.sparse { + parts.push("SPARSE".to_owned()); + } + } let identity = column.auto_increment || attributes.is_some_and(|attributes| attributes.identity == IdentityStyle::MssqlIdentity); if identity { @@ -280,6 +307,48 @@ impl MssqlDialect { } } +/// The column's own collation, kept where its type still holds text. The +/// catalogue reports one only where it differs from the database's. +fn collate_clause( + column: &DraftColumn, + resolved: &ResolvedType, + attributes: &ColumnAttributes, + operation: DdlOperation, +) -> Result, BuildDdlError> { + let textual = matches!(column.column_type, DraftType::Catalog(_)) + || matches!(resolved.column_type.kind, ColumnKind::Text { .. }); + let Some(collation) = attributes.collation.as_ref().filter(|_| textual) else { + return Ok(None); + }; + match collation_permission(attributes) { + Permission::Allowed => Ok(Some(format!("COLLATE {}", collation.name))), + Permission::Refused(reason) => Err(BuildDdlError::Refused { + column: column.name.clone(), + operation, + reason, + }), + } +} + +/// T-SQL takes a collation name bare, never bracketed, so only a name of +/// letters, digits and underscores, which every collation has, can be +/// restated. +fn collation_permission(attributes: &ColumnAttributes) -> Permission { + match &attributes.collation { + Some(collation) if !plain_name(&collation.name) => { + Permission::Refused(RefusalReason::UnmodeledAttribute(format!("COLLATE {}", collation.name))) + } + _ => Permission::Allowed, + } +} + +fn plain_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_') +} + fn default_clause(default: &ColumnDefault, sql: &str) -> String { match default { ColumnDefault::Expression(_) => parenthesized(sql), @@ -323,10 +392,7 @@ fn column_comment(table: &TableRef, column: &str, had_comment: bool, comment: Op #[cfg(test)] mod tests { use tablepro_core::column::{ColumnInfo, SqlExpression}; - use tablepro_core::ddl::{ - ColumnAttributes, DdlOperation, DraftType, DropTableOptions, InboundForeignKey, PrimaryKeyConstraint, - diff_table, - }; + use tablepro_core::ddl::{CollationName, DropTableOptions, InboundForeignKey, PrimaryKeyConstraint, diff_table}; use tablepro_core::value::Value; use super::*; @@ -487,6 +553,59 @@ mod tests { ); } + #[test] + fn mssql_nullability_change_restates_collation_and_sparse() { + let mut original = orders(); + original.columns[2].attributes.collation = Some(CollationName::new(None, "Latin1_General_100_CS_AS")); + let mut weight = column("weight", "int", None); + weight.attributes.sparse = true; + original.columns.push(weight); + let mut code = column("code", "varchar(10)", None); + code.attributes.collation = Some(CollationName::new(None, "Latin1_General_BIN")); + original.columns.push(code); + + let plan = alter(&original, |draft| { + draft.columns[2].nullable = false; + draft.columns[3].column_type = DraftType::Typed("bigint".to_owned()); + draft.columns[4].column_type = DraftType::Typed("int".to_owned()); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE [dbo].[orders] ALTER COLUMN [note] nvarchar(20) COLLATE Latin1_General_100_CS_AS NOT NULL", + "ALTER TABLE [dbo].[orders] ALTER COLUMN [weight] bigint SPARSE NULL", + "ALTER TABLE [dbo].[orders] ALTER COLUMN [code] int NULL", + ] + ); + assert_eq!( + sql(dialect().create_table_script(&original))[0], + "CREATE TABLE [dbo].[orders] (\n [id] int NOT NULL,\n [status] int NULL DEFAULT ((0)),\n [note] \ + nvarchar(20) COLLATE Latin1_General_100_CS_AS NULL,\n [weight] int SPARSE NULL,\n [code] varchar(10) \ + COLLATE Latin1_General_BIN NULL,\n PRIMARY KEY ([id])\n)" + ); + } + + #[test] + fn a_collation_tsql_cannot_name_bare_refuses_a_restatement() { + let mut original = orders(); + original.columns[2].attributes.collation = Some(CollationName::new(None, "odd]name")); + + let permissions = dialect().column_capabilities(&original, &original.columns[2]); + + let odd = Permission::Refused(RefusalReason::UnmodeledAttribute("COLLATE odd]name".to_owned())); + assert_eq!(permissions.alter_type, odd); + assert_eq!(permissions.alter_nullability, odd); + assert_eq!(permissions.alter_comment, Permission::Allowed); + assert!(matches!( + dialect().create_table_script(&original), + Err(BuildDdlError::Refused { + operation: DdlOperation::CreateTable, + .. + }) + )); + } + #[test] fn mssql_default_drop_uses_catalog_name_with_bracket_escaping() { let cleared = alter(&orders(), |draft| draft.columns[1].default = ColumnDefault::None); diff --git a/linux/crates/drivers/mssql/tests/integration/ddl.rs b/linux/crates/drivers/mssql/tests/integration/ddl.rs new file mode 100644 index 0000000000..0f58d2334c --- /dev/null +++ b/linux/crates/drivers/mssql/tests/integration/ddl.rs @@ -0,0 +1,69 @@ +//! Structure changes the SQL Server dialect plans, run against a real +//! server. + +use tablepro_core::Connection; +use tablepro_core::ddl::{DraftType, TableDefinition, TableDraft, diff_table}; + +use crate::support::{CALL, connect, dbo, setup, start, within}; + +async fn read(connection: &dyn Connection) -> TableDefinition { + connection + .fetch_table_definition(&dbo("kept"), within(CALL)) + .await + .expect("the definition") +} + +/// ALTER COLUMN gives the column the database's collation and drops +/// SPARSE unless the statement restates them. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn nullability_change_keeps_column_collation() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE dbo.kept (id int PRIMARY KEY, code varchar(20) COLLATE Latin1_General_BIN NULL, \ + name nvarchar(40) COLLATE Latin1_General_100_CS_AS NOT NULL, weight int SPARSE NULL)", + ], + ) + .await; + let before = read(connection.as_ref()).await; + let mut draft = TableDraft::from_definition(&before); + for column in &mut draft.columns { + match column.name.as_str() { + "code" => column.nullable = false, + "name" => column.column_type = DraftType::Typed("nvarchar(80)".to_owned()), + "weight" => column.column_type = DraftType::Typed("bigint".to_owned()), + _ => {} + } + } + let dialect = connection.dialect(); + let plan = dialect + .ddl() + .expect("SQL Server plans its DDL") + .plan_alter_table(&before, &diff_table(&before, &draft)) + .expect("the change plans"); + + connection + .write_batch(&plan.into_write_batch(dbo("kept")), within(CALL)) + .await + .expect("the change runs"); + let after = read(connection.as_ref()).await; + + let column = |name: &str| after.column(name).expect("the column"); + let collation = |name: &str| { + column(name) + .attributes + .collation + .as_ref() + .map(|collation| collation.name.clone()) + }; + assert!(!column("code").info.nullable); + assert_eq!(collation("code").as_deref(), Some("Latin1_General_BIN")); + assert!(!column("name").info.nullable); + assert_eq!(collation("name").as_deref(), Some("Latin1_General_100_CS_AS")); + assert_eq!(column("name").info.column_type.name, "nvarchar(80)"); + assert!(column("weight").attributes.sparse); + assert_eq!(column("weight").info.column_type.name, "bigint"); +} diff --git a/linux/crates/drivers/mssql/tests/integration/main.rs b/linux/crates/drivers/mssql/tests/integration/main.rs index 2a06420776..f0188ef6aa 100644 --- a/linux/crates/drivers/mssql/tests/integration/main.rs +++ b/linux/crates/drivers/mssql/tests/integration/main.rs @@ -9,6 +9,7 @@ mod cancellation; mod conformance_engine; +mod ddl; mod decode; mod filter; mod metadata; From 3222ad6655c0b4e968532d5c52ba96fcf999eca4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:49:32 +0700 Subject: [PATCH 132/178] test(linux): fake a write that answers once stopped and keep its cancel --- .../test-support/src/fake_connection.rs | 45 +++++++++++++++++++ linux/crates/test-support/src/fake_reply.rs | 10 +++++ 2 files changed, 55 insertions(+) diff --git a/linux/crates/test-support/src/fake_connection.rs b/linux/crates/test-support/src/fake_connection.rs index b70706a73a..241e611e50 100644 --- a/linux/crates/test-support/src/fake_connection.rs +++ b/linux/crates/test-support/src/fake_connection.rs @@ -32,6 +32,8 @@ pub struct FakeConnection { link: LinkWatch, closed: CancellationToken, counters: CallCounters, + /// The cancel token each call was handed, by method, in call order. + cancels: Mutex>, } impl FakeConnection { @@ -46,6 +48,7 @@ impl FakeConnection { link: LinkWatch::new(), closed: CancellationToken::new(), counters: CallCounters::default(), + cancels: Mutex::new(Vec::new()), } } @@ -63,6 +66,16 @@ impl FakeConnection { self.closed.is_cancelled() } + /// The cancel token every call to `method` was handed, oldest first, + /// so a test can tell its own stop reached the call. + pub fn observed_cancels(&self, method: &str) -> Vec { + lock(&self.cancels) + .iter() + .filter(|(called, _)| *called == method) + .map(|(_, cancel)| cancel.clone()) + .collect() + } + async fn answer( &self, method: &'static str, @@ -76,6 +89,7 @@ impl FakeConnection { T: Clone, { CallCounters::record(counter); + lock(&self.cancels).push((method, options.cancel().clone())); if self.closed.is_cancelled() { CallCounters::record(&self.counters.stopped); return Err(DriverError::Cancelled); @@ -586,4 +600,35 @@ mod tests { assert_eq!(result.row_count(), 3); assert!(result.truncated); } + + #[tokio::test] + async fn a_stopped_with_reply_answers_its_outcome_only_once_cancelled() { + let mut script = ConnectionScript::default(); + script + .query_bound + .always(FakeReply::StoppedWith(Err(DriverError::Busy))); + let connection = Arc::new(FakeConnection::new(script)); + let cancel = CancellationToken::new(); + + let waiting = { + let connection = connection.clone(); + let options = CallOptions::new(None, cancel.clone()); + tokio::spawn(async move { + connection + .query_bound(&select("SELECT 1"), RowLimit::EDITOR_DEFAULT, options) + .await + }) + }; + tokio::task::yield_now().await; + assert!(!waiting.is_finished(), "the call answered before it was stopped"); + let observed = connection.observed_cancels("query_bound"); + assert_eq!(observed.len(), 1); + assert!(!observed[0].is_cancelled()); + + cancel.cancel(); + + assert!(matches!(waiting.await.unwrap(), Err(DriverError::Busy))); + assert!(observed[0].is_cancelled(), "the call was handed another token"); + assert!(connection.observed_cancels("write_batch").is_empty()); + } } diff --git a/linux/crates/test-support/src/fake_reply.rs b/linux/crates/test-support/src/fake_reply.rs index 05fd19f656..f9c3fbbd80 100644 --- a/linux/crates/test-support/src/fake_reply.rs +++ b/linux/crates/test-support/src/fake_reply.rs @@ -28,6 +28,10 @@ pub enum FakeReply { /// Never answer on its own: only the call's deadline or cancel ends /// it, the way a statement the server never finishes behaves. UntilStopped, + /// Never answer on its own, then answer `outcome` once the call's + /// deadline or cancel ends it, the way a driver that rolls a write + /// back on cancel reports it. + StoppedWith(Result), /// Never answer at all. Hang, } @@ -56,6 +60,12 @@ impl FakeReply { stop.wait(None).await?; Err(DriverError::Protocol("an unbounded fake call finished".to_owned())) } + Self::StoppedWith(outcome) => { + // Only a stop ends the wait, so its error is replaced by + // the answer the call was scripted to give. + let _ = stop.wait(None).await; + outcome + } Self::Hang => std::future::pending().await, } } From 8535cad6d4cb544acaa9415b76f7f7be4736cbb5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:50:01 +0700 Subject: [PATCH 133/178] fix(linux): make saving, closing and logout event-driven Tab close, window close, disconnect and switch share one CloseGuard. Window close also confirms open editor transactions and closes editor tab connections before the window's session. --- linux/CHANGELOG.md | 8 + .../crates/app/src/services/change_tracker.rs | 172 ++++- linux/crates/app/src/services/mod.rs | 1 + linux/crates/app/src/services/save_batch.rs | 23 + .../app/src/services/structure_tracker.rs | 89 ++- .../src/services/structure_tracker_handle.rs | 13 +- .../app/src/services/tab_tracker_handle.rs | 148 ++++ linux/crates/app/src/ui/app/close_flow.rs | 229 ++++++ linux/crates/app/src/ui/app/connection.rs | 7 +- linux/crates/app/src/ui/app/import.rs | 2 +- linux/crates/app/src/ui/app/mod.rs | 404 +++-------- linux/crates/app/src/ui/app/row_ops.rs | 238 +------ linux/crates/app/src/ui/app/saves.rs | 118 ++++ linux/crates/app/src/ui/app/structure.rs | 194 +----- linux/crates/app/src/ui/app/teardown_guard.rs | 115 --- linux/crates/app/src/ui/app/window_tabs.rs | 59 ++ linux/crates/app/src/ui/app/workspace_tabs.rs | 182 +---- linux/crates/app/src/ui/browse_tab/imp.rs | 9 +- linux/crates/app/src/ui/browse_tab/mod.rs | 114 +-- linux/crates/app/src/ui/browse_tab/tests.rs | 42 +- linux/crates/app/src/ui/mod.rs | 2 +- linux/crates/app/src/ui/structure_tab/imp.rs | 5 + linux/crates/app/src/ui/structure_tab/mod.rs | 69 +- .../crates/app/src/ui/structure_tab/tests.rs | 53 +- .../app/src/workspace/close_decision.rs | 7 + linux/crates/app/src/workspace/close_guard.rs | 352 ++++++++++ linux/crates/app/src/workspace/close_scope.rs | 19 + linux/crates/app/src/workspace/close_step.rs | 97 +++ .../app/src/workspace/dialog_response.rs | 51 ++ linux/crates/app/src/workspace/dirty_kind.rs | 12 + linux/crates/app/src/workspace/dirty_tab.rs | 23 + .../app/src/workspace/inhibit_reason.rs | 36 + linux/crates/app/src/workspace/mod.rs | 62 +- .../src/workspace/open_transaction_dialog.rs | 27 + .../crates/app/src/workspace/pending_state.rs | 12 + .../{ui/app => workspace}/ran_statements.rs | 6 +- .../app/src/workspace/rollback_cause.rs | 36 + .../app/src/workspace/save_changes_dialog.rs | 77 ++ linux/crates/app/src/workspace/save_id.rs | 9 + linux/crates/app/src/workspace/save_ledger.rs | 283 ++++++++ .../crates/app/src/workspace/save_outcome.rs | 176 +++++ .../app/src/workspace/save_rejection.rs | 27 + .../crates/app/src/workspace/save_settled.rs | 19 + .../crates/app/src/workspace/save_snapshot.rs | 13 + linux/crates/app/src/workspace/save_start.rs | 9 + linux/crates/app/src/workspace/save_ticket.rs | 11 + .../crates/app/src/workspace/schema_write.rs | 44 ++ .../crates/app/src/workspace/tab_directory.rs | 8 + linux/crates/app/src/workspace/tab_entry.rs | 11 + .../app/src/workspace/test_workspace.rs | 171 +++++ .../crates/app/src/workspace/unknown_cause.rs | 39 ++ .../src/workspace/unknown_outcome_dialog.rs | 45 ++ .../app/src/workspace/waiting_dialog.rs | 64 ++ .../app/src/workspace/window_inhibitor.rs | 216 ++++++ .../app/src/workspace/workspace_event.rs | 18 + .../app/src/workspace/workspace_handle.rs | 655 ++++++++++++++++++ linux/data/resources/ui/browse-tab.ui | 16 + linux/data/resources/ui/structure-tab.ui | 16 + linux/po/POTFILES.in | 37 +- 59 files changed, 3895 insertions(+), 1105 deletions(-) create mode 100644 linux/crates/app/src/services/save_batch.rs create mode 100644 linux/crates/app/src/ui/app/close_flow.rs create mode 100644 linux/crates/app/src/ui/app/saves.rs delete mode 100644 linux/crates/app/src/ui/app/teardown_guard.rs create mode 100644 linux/crates/app/src/ui/app/window_tabs.rs create mode 100644 linux/crates/app/src/workspace/close_decision.rs create mode 100644 linux/crates/app/src/workspace/close_guard.rs create mode 100644 linux/crates/app/src/workspace/close_scope.rs create mode 100644 linux/crates/app/src/workspace/close_step.rs create mode 100644 linux/crates/app/src/workspace/dialog_response.rs create mode 100644 linux/crates/app/src/workspace/dirty_kind.rs create mode 100644 linux/crates/app/src/workspace/dirty_tab.rs create mode 100644 linux/crates/app/src/workspace/inhibit_reason.rs create mode 100644 linux/crates/app/src/workspace/open_transaction_dialog.rs create mode 100644 linux/crates/app/src/workspace/pending_state.rs rename linux/crates/app/src/{ui/app => workspace}/ran_statements.rs (96%) create mode 100644 linux/crates/app/src/workspace/rollback_cause.rs create mode 100644 linux/crates/app/src/workspace/save_changes_dialog.rs create mode 100644 linux/crates/app/src/workspace/save_id.rs create mode 100644 linux/crates/app/src/workspace/save_ledger.rs create mode 100644 linux/crates/app/src/workspace/save_outcome.rs create mode 100644 linux/crates/app/src/workspace/save_rejection.rs create mode 100644 linux/crates/app/src/workspace/save_settled.rs create mode 100644 linux/crates/app/src/workspace/save_snapshot.rs create mode 100644 linux/crates/app/src/workspace/save_start.rs create mode 100644 linux/crates/app/src/workspace/save_ticket.rs create mode 100644 linux/crates/app/src/workspace/schema_write.rs create mode 100644 linux/crates/app/src/workspace/tab_directory.rs create mode 100644 linux/crates/app/src/workspace/tab_entry.rs create mode 100644 linux/crates/app/src/workspace/test_workspace.rs create mode 100644 linux/crates/app/src/workspace/unknown_cause.rs create mode 100644 linux/crates/app/src/workspace/unknown_outcome_dialog.rs create mode 100644 linux/crates/app/src/workspace/waiting_dialog.rs create mode 100644 linux/crates/app/src/workspace/window_inhibitor.rs create mode 100644 linux/crates/app/src/workspace/workspace_event.rs create mode 100644 linux/crates/app/src/workspace/workspace_handle.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index bfc60fefcc..11908e580b 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQLite file chooser in the connect dialog, with New Database to create a file. - Results list in the SQL editor with one entry per statement, its rows affected or error, and the open transaction. - SQL editor completion of tables, views and columns from the database, resolving aliases and matching fuzzily. +- Stop Saving in table and structure tabs, and in the dialog that waits for a save before closing. +- Save Changes dialog on close that lists every tab with unsaved changes and saves only the ones left checked. +- Logout waits while there are unsaved changes, and logout and suspend wait while a save is running. ### Changed @@ -70,6 +73,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Editor tabs reopened after a reconnect in the same window stopped saving their text. - An editor tab whose saved text could not be read opened empty with no notice, and typing in it overwrote that text. - Typing in a long SQL script copied the whole script on every keystroke. +- Closing the window never asked about open editor transactions, and never closed the editor tabs' connections. +- Disconnecting or switching connections offered no way to save pending changes first. +- A save whose answer was lost with the connection showed as failed, and saving again could apply it twice. +- Edits made in a table tab while its save ran were dropped when the save finished. +- Ctrl+S did nothing in a Structure tab. ### Security diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index 7f963d9a65..98160245a9 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -9,12 +9,15 @@ use tablepro_core::edit::CellInput; use tablepro_core::meta::RowIdentity; use tablepro_core::save::{ChangeSet, RowChange, RowUpdate}; use tablepro_core::{ColumnInfo, Value}; +use tablepro_session::OperationError; use super::cell_overlay::CellOverlay; +use super::save_batch::{SaveBatch, SaveTarget}; use super::touch::Touch; use super::tracker_delta::{TrackerDelta, VisualChange}; use super::tracker_edit::TrackerEdit; use super::undo_op::UndoOp; +use crate::workspace::{PendingState, SaveOutcome, SaveRejection, SaveSnapshot, UnknownCause}; const UNDO_LIMIT: usize = 50; @@ -116,6 +119,14 @@ pub struct TabChangeTracker { // A flash that starts inside an older flash's timeout must not be // cleared by that older timer. error_row_gen: u64, + /// The table and columns the tab last read, which a save is planned + /// against. + target: Option, + /// Where the save in flight ends. + saving: Option, + /// A save that may have landed. Saving again could apply it twice, + /// so nothing saves until the tab is reloaded or discarded. + outcome_unknown: Option, } impl TabChangeTracker { @@ -124,6 +135,9 @@ impl TabChangeTracker { } pub fn apply(&mut self, edit: TrackerEdit) -> TrackerDelta { + if self.edit_is_in_flight(&edit) { + return self.delta(Vec::new(), Vec::new()); + } match edit { TrackerEdit::Cell { row_key, @@ -141,6 +155,36 @@ impl TabChangeTracker { } } + /// An edit that would change what the running save already sent. + /// An insert or a delete cannot be sent twice, so a draft or a + /// delete in the save stays as it is until the save settles; a cell + /// edit is a new value, which the next save writes. + fn edit_is_in_flight(&self, edit: &TrackerEdit) -> bool { + let Some(snapshot) = self.saving else { + return false; + }; + let draft_in_flight = |draft_id: u64| { + self.drafts + .iter() + .any(|draft| draft.draft_id == draft_id && snapshot.includes(draft.touched)) + }; + let delete_in_flight = |row_key: &RowKey| { + self.deletes + .get(row_key) + .is_some_and(|mark| snapshot.includes(mark.touched)) + }; + match edit { + TrackerEdit::DraftCell { draft_id, .. } | TrackerEdit::DiscardDraft { draft_id } => { + draft_in_flight(*draft_id) + } + TrackerEdit::Cell { row_key, .. } | TrackerEdit::Delete { row_key, .. } => match row_key { + RowKey::Draft(draft_id) => draft_in_flight(*draft_id), + RowKey::Persisted(_) => delete_in_flight(row_key), + }, + TrackerEdit::Insert { .. } => false, + } + } + fn delta(&self, rows: Vec, visual: Vec) -> TrackerDelta { TrackerDelta { rows, @@ -165,8 +209,13 @@ impl TabChangeTracker { fn track_cell_edit(&mut self, row_key: RowKey, col: usize, original: Value, value: Value) -> TrackerDelta { let before = self.updates.get(&(row_key.clone(), col)).cloned(); + // The running save is writing `before`, so even a value equal to + // the fetched one is a change the next save has to write back. + let in_flight = before + .as_ref() + .is_some_and(|edit| self.saving.is_some_and(|snapshot| snapshot.includes(edit.touched))); let original = before.as_ref().map_or(original, |edit| edit.original.clone()); - let after = (value != original).then(|| CellEdit { + let after = (in_flight || value != original).then(|| CellEdit { original, pending: value, touched: self.touch(), @@ -295,7 +344,12 @@ impl TabChangeTracker { ) } + /// Nothing while a save runs: stepping back through edits the save + /// already sent would show a state the database is not left in. pub fn undo(&mut self) -> Option { + if self.saving.is_some() { + return None; + } let op = self.undo.pop_back()?; let delta = match &op { UndoOp::CellEdit { @@ -316,6 +370,9 @@ impl TabChangeTracker { } pub fn redo(&mut self) -> Option { + if self.saving.is_some() { + return None; + } let op = self.redo.pop_back()?; let delta = match &op { UndoOp::CellEdit { @@ -345,8 +402,10 @@ impl TabChangeTracker { !self.redo.is_empty() } - /// Drop every pending change and the undo history. + /// Drop every pending change and the undo history. A save whose + /// outcome was unknown is resolved: the tab reads the table again. pub fn discard(&mut self) -> TrackerDelta { + self.outcome_unknown = None; let mut seen = HashSet::new(); let rows: Vec = self .updates @@ -516,6 +575,115 @@ impl TabChangeTracker { sources, }) } + + pub(crate) fn set_target(&mut self, target: SaveTarget) { + self.target = Some(target); + } + + pub(crate) fn pending_state(&self) -> PendingState { + if let Some(cause) = &self.outcome_unknown { + return PendingState::OutcomeUnknown(cause.clone()); + } + if self.saving.is_some() { + return PendingState::Saving; + } + match self.pending_count() { + 0 => PendingState::Clean, + _ => PendingState::Unsaved, + } + } + + /// Take what Save writes, and hold it as in flight until it settles. + /// `None` when there is nothing to save. + pub(crate) fn begin_save(&mut self) -> Result, SaveRejection> { + if self.outcome_unknown.is_some() { + return Err(SaveRejection::OutcomeUnknown); + } + if self.saving.is_some() { + return Err(SaveRejection::AlreadySaving); + } + if self.pending_count() == 0 { + return Ok(None); + } + let Some(target) = self.target.clone() else { + return Err(SaveRejection::Invalid(crate::i18n::gettext( + "The table's columns have not been read yet. Try again in a moment.", + ))); + }; + let changes = self.change_set(&target.columns).map_err(|error| { + SaveRejection::Invalid(crate::ui::error_text::operation_error_message(&OperationError::Build( + error, + ))) + })?; + if changes.changes.is_empty() { + return Ok(None); + } + let snapshot = SaveSnapshot(self.next_touch); + self.saving = Some(snapshot); + Ok(Some(SaveBatch { + table: target.table, + columns: target.columns, + changes, + snapshot, + })) + } + + /// End the save `snapshot` began. A commit drops what it wrote and + /// keeps every edit made while it ran; a rollback keeps everything; + /// an unknown outcome keeps everything and refuses the next save. + pub(crate) fn settle_save(&mut self, outcome: &SaveOutcome, snapshot: SaveSnapshot) -> TrackerDelta { + if self.saving == Some(snapshot) { + self.saving = None; + } + match outcome { + SaveOutcome::Committed { .. } => self.drop_saved(snapshot), + SaveOutcome::RolledBack(_) => self.delta(Vec::new(), Vec::new()), + SaveOutcome::Unknown(cause) => { + self.mark_outcome_unknown(cause.clone()); + self.delta(Vec::new(), Vec::new()) + } + } + } + + pub(crate) fn mark_outcome_unknown(&mut self, cause: UnknownCause) { + self.outcome_unknown = Some(cause); + } + + fn drop_saved(&mut self, snapshot: SaveSnapshot) -> TrackerDelta { + let mut rows = Vec::new(); + let mut visual = Vec::new(); + self.updates.retain(|(row_key, _), edit| { + let saved = snapshot.includes(edit.touched); + if saved { + rows.push(row_key.clone()); + } + !saved + }); + self.deletes.retain(|row_key, mark| { + let saved = snapshot.includes(mark.touched); + if saved { + rows.push(row_key.clone()); + } + !saved + }); + self.drafts.retain(|draft| { + let saved = snapshot.includes(draft.touched); + if saved { + rows.push(RowKey::Draft(draft.draft_id)); + visual.push(VisualChange::RemoveDraft { + draft_id: draft.draft_id, + }); + } + !saved + }); + // The history describes edits against rows as they were read, + // and the saved ones are now the rows themselves. + self.undo.clear(); + self.redo.clear(); + let mut seen = HashSet::new(); + rows.retain(|row_key| seen.insert(row_key.clone())); + self.delta(rows, visual) + } } /// The tracker's changes in the form a save plans from, with the row diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index 5d732cb739..f71f0e2d78 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -6,6 +6,7 @@ pub mod from_session; pub mod history_availability; pub mod history_service; pub mod pending_structure; +pub mod save_batch; pub mod secret_labels; pub mod secret_save_report; pub mod session_context; diff --git a/linux/crates/app/src/services/save_batch.rs b/linux/crates/app/src/services/save_batch.rs new file mode 100644 index 0000000000..d33dad42a6 --- /dev/null +++ b/linux/crates/app/src/services/save_batch.rs @@ -0,0 +1,23 @@ +use tablepro_core::ColumnInfo; +use tablepro_core::meta::TableRef; + +use super::change_tracker::TrackedChanges; +use crate::workspace::SaveSnapshot; + +/// The table a tab's rows come from, as the tab last read it. +#[derive(Debug, Clone, PartialEq)] +pub struct SaveTarget { + pub table: TableRef, + pub columns: Vec, +} + +/// What one save of a tab's rows writes. The columns are the ones the +/// changes were built against, so the plan binds the same shape, and +/// `changes` maps a failing step back to its grid row. +#[derive(Debug, Clone)] +pub struct SaveBatch { + pub table: TableRef, + pub columns: Vec, + pub changes: TrackedChanges, + pub snapshot: SaveSnapshot, +} diff --git a/linux/crates/app/src/services/structure_tracker.rs b/linux/crates/app/src/services/structure_tracker.rs index 9d30067ed6..492294ead6 100644 --- a/linux/crates/app/src/services/structure_tracker.rs +++ b/linux/crates/app/src/services/structure_tracker.rs @@ -4,10 +4,15 @@ //! back to the table as it was read. use super::pending_structure::PendingStructure; +use crate::workspace::{PendingState, SaveOutcome, SaveRejection, UnknownCause}; #[derive(Debug, Default)] pub struct StructureChangeTracker { pending: Option, + saving: bool, + /// A save that may have landed. Nothing saves until the tab reads + /// the table again or its changes are discarded. + outcome_unknown: Option, } impl StructureChangeTracker { @@ -19,13 +24,55 @@ impl StructureChangeTracker { self.pending = pending; } - /// What Save writes, or `None` when there is nothing to. - pub fn begin_save(&self) -> Option { - self.pending.clone().filter(PendingStructure::has_changes) + pub fn pending_state(&self) -> PendingState { + if let Some(cause) = &self.outcome_unknown { + return PendingState::OutcomeUnknown(cause.clone()); + } + if self.saving { + return PendingState::Saving; + } + match self.has_pending() { + true => PendingState::Unsaved, + false => PendingState::Clean, + } + } + + /// What Save writes, held as in flight until it settles, or `None` + /// when there is nothing to. The tab's own checks run here too, so a + /// save from a close prompt cannot skip them. + pub fn begin_save(&mut self) -> Result, SaveRejection> { + if self.outcome_unknown.is_some() { + return Err(SaveRejection::OutcomeUnknown); + } + if self.saving { + return Err(SaveRejection::AlreadySaving); + } + let Some(pending) = self.pending.clone().filter(PendingStructure::has_changes) else { + return Ok(None); + }; + pending + .check() + .map_err(|blocker| SaveRejection::Invalid(crate::ui::structure_tab::save_blocker_text(&blocker)))?; + self.saving = true; + Ok(Some(pending)) } + /// A commit leaves nothing pending: the tab reads the table again and + /// diffs against what is there now. + pub fn settle_save(&mut self, outcome: &SaveOutcome) { + self.saving = false; + match outcome { + SaveOutcome::Committed { .. } => self.pending = None, + SaveOutcome::RolledBack(_) => {} + SaveOutcome::Unknown(cause) => self.outcome_unknown = Some(cause.clone()), + } + } + + /// Drop what is pending, and with it a save whose outcome was + /// unknown: the tab reads the table again. pub fn clear(&mut self) { self.pending = None; + self.outcome_unknown = None; } } @@ -54,10 +101,10 @@ mod tests { #[test] fn empty_tracker_has_no_pending() { - let tracker = StructureChangeTracker::default(); + let mut tracker = StructureChangeTracker::default(); assert!(!tracker.has_pending()); - assert_eq!(tracker.begin_save(), None); + assert_eq!(tracker.begin_save(), Ok(None)); } #[test] @@ -65,13 +112,39 @@ mod tests { let mut tracker = StructureChangeTracker::default(); tracker.set_pending(Some(new_table(0))); assert!(!tracker.has_pending()); - assert_eq!(tracker.begin_save(), None); + assert_eq!(tracker.begin_save(), Ok(None)); tracker.set_pending(Some(new_table(1))); assert!(tracker.has_pending()); - assert_eq!(tracker.begin_save(), Some(new_table(1))); + assert_eq!(tracker.begin_save(), Ok(Some(new_table(1)))); + tracker.settle_save(&SaveOutcome::Committed { rows: None }); - tracker.clear(); assert!(!tracker.has_pending()); } + + #[test] + fn a_save_runs_once_and_an_unknown_outcome_holds_the_next() { + let mut tracker = StructureChangeTracker::default(); + tracker.set_pending(Some(new_table(1))); + + assert!(matches!(tracker.begin_save(), Ok(Some(_)))); + assert_eq!(tracker.begin_save(), Err(SaveRejection::AlreadySaving)); + tracker.settle_save(&SaveOutcome::Unknown(UnknownCause::Closed)); + + assert_eq!(tracker.begin_save(), Err(SaveRejection::OutcomeUnknown)); + assert!(tracker.has_pending()); + tracker.clear(); + assert_eq!(tracker.pending_state(), PendingState::Clean); + } + + #[test] + fn a_draft_the_tab_would_refuse_is_refused_here_too() { + let mut tracker = StructureChangeTracker::default(); + let mut unnamed = new_table(1); + unnamed.draft.columns[0].name = String::new(); + tracker.set_pending(Some(unnamed)); + + assert!(matches!(tracker.begin_save(), Err(SaveRejection::Invalid(_)))); + assert_eq!(tracker.pending_state(), PendingState::Unsaved); + } } diff --git a/linux/crates/app/src/services/structure_tracker_handle.rs b/linux/crates/app/src/services/structure_tracker_handle.rs index 312cd68c89..4fa0a9cd0a 100644 --- a/linux/crates/app/src/services/structure_tracker_handle.rs +++ b/linux/crates/app/src/services/structure_tracker_handle.rs @@ -3,6 +3,7 @@ use std::rc::Rc; use super::pending_structure::PendingStructure; use super::structure_tracker::StructureChangeTracker; +use crate::workspace::{PendingState, SaveOutcome, SaveRejection}; /// A Structure tab's pending save. Every method borrows only for its own /// body and returns owned data. @@ -14,8 +15,16 @@ impl StructureTrackerHandle { self.0.borrow_mut().set_pending(pending); } - pub fn begin_save(&self) -> Option { - self.0.borrow().begin_save() + pub fn begin_save(&self) -> Result, SaveRejection> { + self.0.borrow_mut().begin_save() + } + + pub fn settle_save(&self, outcome: &SaveOutcome) { + self.0.borrow_mut().settle_save(outcome); + } + + pub fn pending_state(&self) -> PendingState { + self.0.borrow().pending_state() } pub fn has_pending(&self) -> bool { diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs index b3bf902f5c..330521b082 100644 --- a/linux/crates/app/src/services/tab_tracker_handle.rs +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -6,8 +6,10 @@ use tablepro_core::{ColumnInfo, Value}; use super::cell_overlay::CellOverlay; use super::change_tracker::{DraftRow, RowKey, RowState, TabChangeTracker, TrackedChanges}; +use super::save_batch::{SaveBatch, SaveTarget}; use super::tracker_delta::TrackerDelta; use super::tracker_edit::TrackerEdit; +use crate::workspace::{PendingState, SaveOutcome, SaveRejection, SaveSnapshot}; /// A browse tab's tracker. Each method borrows only for its own body /// and hands back owned data, so a grid bind that runs while the @@ -63,17 +65,163 @@ impl TabTrackerHandle { pub fn clear_error_row_if(&self, generation: u64) -> bool { self.0.borrow_mut().clear_error_row_if(generation) } + + /// The table and columns the tab read, which every save is planned + /// against. + pub(crate) fn set_target(&self, target: SaveTarget) { + self.0.borrow_mut().set_target(target); + } + + pub(crate) fn pending_state(&self) -> PendingState { + self.0.borrow().pending_state() + } + + pub(crate) fn begin_save(&self) -> Result, SaveRejection> { + self.0.borrow_mut().begin_save() + } + + #[must_use = "the grid shows stale rows until the delta is applied"] + pub(crate) fn settle_save(&self, outcome: &SaveOutcome, snapshot: SaveSnapshot) -> TrackerDelta { + self.0.borrow_mut().settle_save(outcome, snapshot) + } } #[cfg(test)] mod tests { + use tablepro_core::DriverError; + use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType, IntegerKind, IntegerWidth}; + use tablepro_core::meta::TableRef; + use super::*; use crate::services::tracker_delta::VisualChange; + use crate::workspace::{RollbackCause, UnknownCause}; fn row(id: i64) -> RowKey { RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") } + fn integer(name: &str, primary_key: bool) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text("integer"); + column_type.kind = ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }); + ColumnInfo { + name: name.to_owned(), + column_type, + nullable: false, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + /// A tracker on a two-column table, with one edit pending. + fn edited() -> TabTrackerHandle { + let tracker = TabTrackerHandle::default(); + tracker.set_target(SaveTarget { + table: TableRef::unqualified("t"), + columns: vec![integer("id", true), integer("n", false)], + }); + edit_cell(&tracker, 1, 2); + tracker + } + + fn edit_cell(tracker: &TabTrackerHandle, id: i64, value: i64) { + let _ = tracker.apply(TrackerEdit::Cell { + row_key: row(id), + col: 1, + original: Value::Int(0), + value: Value::Int(value), + }); + } + + fn rolled_back() -> SaveOutcome { + SaveOutcome::RolledBack(RollbackCause::Driver(DriverError::server("no"))) + } + + #[test] + fn frozen_tracker_rejects_begin_save() { + let tracker = edited(); + let batch = tracker.begin_save().expect("a save").expect("changes to save"); + + assert_eq!(tracker.begin_save().err(), Some(SaveRejection::AlreadySaving)); + let _ = tracker.settle_save(&SaveOutcome::Unknown(UnknownCause::Interrupted), batch.snapshot); + + assert_eq!(tracker.begin_save().err(), Some(SaveRejection::OutcomeUnknown)); + assert_eq!( + tracker.pending_state(), + PendingState::OutcomeUnknown(UnknownCause::Interrupted) + ); + let _ = tracker.discard(); + assert_eq!(tracker.pending_state(), PendingState::Clean); + assert!(matches!(tracker.begin_save(), Ok(None))); + } + + #[test] + fn rolled_back_keeps_dirty() { + let tracker = edited(); + let batch = tracker.begin_save().expect("a save").expect("changes to save"); + assert_eq!(tracker.pending_state(), PendingState::Saving); + + let delta = tracker.settle_save(&rolled_back(), batch.snapshot); + + assert_eq!(delta.pending_count, 1); + assert_eq!(tracker.pending_state(), PendingState::Unsaved); + let again = tracker.begin_save().expect("a save").expect("changes to save"); + assert_eq!(again.changes.changes.changes, batch.changes.changes.changes); + } + + #[test] + fn committed_clears_only_snapshot_edits() { + let tracker = edited(); + let _ = tracker.apply(TrackerEdit::Insert { + values: vec![Value::Int(9), Value::Int(9)], + }); + let batch = tracker.begin_save().expect("a save").expect("changes to save"); + assert_eq!(batch.changes.changes.changes.len(), 2); + + // Made while the save ran: a new value for a cell in the save, + // and an edit to another row. + edit_cell(&tracker, 1, 3); + edit_cell(&tracker, 4, 5); + let delta = tracker.settle_save(&SaveOutcome::Committed { rows: Some(2) }, batch.snapshot); + + assert_eq!(delta.pending_count, 2); + assert!(matches!(delta.visual.as_slice(), [VisualChange::RemoveDraft { .. }])); + assert!(tracker.drafts().is_empty(), "the inserted draft stayed pending"); + assert_eq!(tracker.pending_state(), PendingState::Unsaved); + assert_eq!(tracker.cell_overlay(&row(1), 1, &Value::Int(0)).value, Value::Int(3)); + assert_eq!(tracker.cell_overlay(&row(4), 1, &Value::Int(0)).value, Value::Int(5)); + } + + #[test] + fn an_insert_or_delete_in_flight_is_not_changed_until_it_settles() { + let tracker = edited(); + let inserted = tracker.apply(TrackerEdit::Insert { + values: vec![Value::Int(9), Value::Int(9)], + }); + let draft_id = match inserted.visual.as_slice() { + [VisualChange::InsertDraft { draft_id, .. }] => *draft_id, + other => panic!("an insert adds one draft row: {other:?}"), + }; + let batch = tracker.begin_save().expect("a save").expect("changes to save"); + + let _ = tracker.apply(TrackerEdit::DraftCell { + draft_id, + col: 1, + value: Value::Int(10), + }); + let _ = tracker.apply(TrackerEdit::DiscardDraft { draft_id }); + assert!(tracker.undo().is_none(), "undo ran while a save was in flight"); + + assert_eq!(tracker.drafts()[0].values[1], Value::Int(9)); + let _ = tracker.settle_save(&rolled_back(), batch.snapshot); + assert!(tracker.undo().is_some()); + } + #[test] fn apply_returns_delta_with_pending_count() { let tracker = TabTrackerHandle::default(); diff --git a/linux/crates/app/src/ui/app/close_flow.rs b/linux/crates/app/src/ui/app/close_flow.rs new file mode 100644 index 0000000000..a0da46bf29 --- /dev/null +++ b/linux/crates/app/src/ui/app/close_flow.rs @@ -0,0 +1,229 @@ +use std::cell::Cell; +use std::collections::HashSet; +use std::rc::Rc; + +use relm4::adw::prelude::*; +use relm4::gtk::glib; +use relm4::{ComponentSender, adw, gtk}; +use uuid::Uuid; + +use super::workspace_persistence::{WorkspacePersistence, WorkspaceTabs}; +use super::{App, AppMsg, WorkspaceTab, connection}; +use crate::services::session_context::SessionContext; +use crate::workspace::{CloseDecision, CloseGuard, CloseScope, CloseStep, InhibitReason, WorkspaceHandle}; + +/// Where the window's own close stands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WindowClose { + Open, + /// The prompts are deciding. + Asking, + /// Nothing is left to lose, so the next close request goes through. + Approved, +} + +/// One close prompt at a time per window, so a tab's close and the +/// window's never ask about the same tab at once: a second waits for the +/// first, then reads the workspace as the first left it. +pub(super) struct CloseGuards { + turn: futures::lock::Mutex<()>, + window: Cell, +} + +impl CloseGuards { + pub(super) fn new() -> Rc { + Rc::new(Self { + turn: futures::lock::Mutex::new(()), + window: Cell::new(WindowClose::Open), + }) + } + + async fn run(&self, parent: >k::Widget, handle: &WorkspaceHandle, scope: CloseScope) -> CloseDecision { + let _turn = self.turn.lock().await; + CloseGuard::run(parent, handle, scope).await + } +} + +/// What a window that closes still owes: what it shows written to disk, +/// and its connections closed rather than cut off, in order. +pub(super) struct WindowShutdown { + done: Cell, + parts: WindowShutdownParts, +} + +pub(super) struct WindowShutdownParts { + pub settings: Rc, + pub storage: crate::storage::SharedStorage, + pub drafts: Rc, + pub workspace: Rc, + pub tabs: WorkspaceTabs, + pub context: Rc, + pub connect_attempts: Rc, +} + +impl WindowShutdown { + pub(super) fn new(parts: WindowShutdownParts) -> Rc { + Rc::new(Self { + done: Cell::new(false), + parts, + }) + } + + /// Runs once, from the close request, or from the component's + /// shutdown for a window destroyed without one. `window` is the one + /// still on screen, whose size is kept. + /// + /// The tabs go first: each editor tab closes its own connection, + /// which outlives the window's, and every read on the window's + /// session stops. Only then does the session itself close. Every + /// close holds the process until it ends, so quitting does not cut + /// a connection off mid-close. + pub(super) fn run(&self, window: Option<&adw::ApplicationWindow>) { + if self.done.replace(true) { + return; + } + let parts = &self.parts; + if let Some(window) = window { + crate::ui::window_geometry::persist(window, &parts.settings); + } + self.flush(); + for tab in parts.tabs.borrow().values() { + match tab { + WorkspaceTab::Editor(slot) => slot.editor.shutdown(), + WorkspaceTab::Structure(slot) => slot.structure.shutdown(), + WorkspaceTab::Table(slot) => slot.browse.shutdown(), + } + } + connection::close_window_session(&parts.context.active, &*parts.context.hold, &parts.connect_attempts); + } + + /// The last column drag, filter change, editor text and tab list may + /// still be on their way to disk. + fn flush(&self) { + let parts = &self.parts; + let hold = parts.context.hold.acquire(); + let column_widths = parts.storage.column_widths().flush(); + let filter_settings = parts.storage.filter_settings().flush(); + let editor_drafts = parts.drafts.flush_all(); + let tabs = parts.workspace.detach(); + glib::spawn_future_local(async move { + column_widths.await; + filter_settings.await; + for failure in editor_drafts.await { + tracing::warn!(failure, "an editor draft was not saved"); + } + if let Some(tabs) = tabs + && let Err(error) = tabs.await + { + tracing::debug!(%error, "the open tabs were not saved"); + } + drop(hold); + }); + } +} + +/// The window closes only once its prompts approve. A close request +/// while they are deciding is dropped, since their dialog is already +/// on screen. +pub(super) fn connect_window_close( + window: &adw::ApplicationWindow, + guards: Rc, + handle: WorkspaceHandle, + shutdown: Rc, +) { + window.connect_close_request(move |window| { + match guards.window.get() { + WindowClose::Asking => return glib::Propagation::Stop, + WindowClose::Open if handle.close_step(CloseScope::Window, &HashSet::new()) != CloseStep::Approve => { + guards.window.set(WindowClose::Asking); + let (guards, handle, window) = (guards.clone(), handle.clone(), window.clone()); + glib::spawn_future_local(async move { + let decision = guards.run(window.upcast_ref(), &handle, CloseScope::Window).await; + match decision { + CloseDecision::Approved => { + guards.window.set(WindowClose::Approved); + window.close(); + } + CloseDecision::Cancelled => guards.window.set(WindowClose::Open), + } + }); + return glib::Propagation::Stop; + } + WindowClose::Open | WindowClose::Approved => {} + } + shutdown.run(Some(window)); + glib::Propagation::Proceed + }); +} + +impl App { + /// A tab asked to close. One with nothing to lose closes now; any + /// other waits for the prompts. + pub(super) fn close_workspace_tab_by_id(&mut self, id: Uuid, sender: ComponentSender) { + let Some(tab_view) = self.workspace_tab_view.clone() else { + return; + }; + let scope = CloseScope::Tab(id); + if self.workspace_handle.close_step(scope, &HashSet::new()) == CloseStep::Approve { + self.finish_close_workspace_tab(id, &tab_view); + return; + } + let (guards, handle, window) = ( + self.close_guards.clone(), + self.workspace_handle.clone(), + self.window.clone(), + ); + glib::spawn_future_local(async move { + let decision = guards.run(window.upcast_ref(), &handle, scope).await; + sender.input(AppMsg::TabCloseDecided { + id, + approved: decision == CloseDecision::Approved, + }); + }); + } + + pub(super) fn on_tab_close_decided(&mut self, id: Uuid, approved: bool) { + let Some(tab_view) = self.workspace_tab_view.clone() else { + return; + }; + if approved { + self.finish_close_workspace_tab(id, &tab_view); + return; + } + let page = self.workspace_tabs.borrow().get(&id).map(|tab| match tab { + WorkspaceTab::Editor(slot) => slot.page.clone(), + WorkspaceTab::Structure(slot) => slot.page.clone(), + WorkspaceTab::Table(slot) => slot.page.clone(), + }); + if let Some(page) = page { + tab_view.close_page_finish(&page, false); + } + } + + /// Send `proceed` once every tab may close, for a disconnect or a + /// switch to another connection, which take the tabs with them. + pub(super) fn close_workspace_then(&self, proceed: AppMsg, sender: ComponentSender) { + if self.workspace_handle.close_step(CloseScope::Window, &HashSet::new()) == CloseStep::Approve { + sender.input(proceed); + return; + } + let (guards, handle, window) = ( + self.close_guards.clone(), + self.workspace_handle.clone(), + self.window.clone(), + ); + glib::spawn_future_local(async move { + if guards.run(window.upcast_ref(), &handle, CloseScope::Window).await == CloseDecision::Approved { + sender.input(proceed); + } + }); + } + + /// Logout, and suspend while a save runs, wait for the user while + /// the window holds something they would lose. + pub(super) fn refresh_inhibitor(&mut self) { + let dirty = !self.workspace_handle.dirty_tabs().is_empty(); + let saving = self.workspace_handle.ledger().in_flight() > 0; + self.inhibitor.update(InhibitReason::for_state(dirty, saving)); + } +} diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index 16ae867d1b..8bb067b103 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -25,7 +25,6 @@ use crate::ui::file_read::{FileReadError, read_capped_file}; use crate::ui::ssh_inputs::SshEnvironment; use super::connect_flow::{self, SubmitError}; -use super::teardown_guard::Teardown; use super::{App, AppMsg, qualified_label}; /// What a failed connect can be retried from: the connection as it was @@ -153,7 +152,7 @@ impl App { pub(super) fn on_connect_submission(&mut self, submission: ConnectSubmission, sender: ComponentSender) { if self.context.active.is_connected() { let proceed = AppMsg::ConnectSubmissionConfirmed(Box::new(submission)); - self.confirm_teardown(Teardown::Switch, proceed, sender); + self.close_workspace_then(proceed, sender); return; } self.connect_submission(submission, sender); @@ -425,7 +424,7 @@ impl App { } pub(super) fn on_disconnect(&mut self, sender: ComponentSender) { - self.confirm_teardown(Teardown::Disconnect, AppMsg::ForceDisconnect, sender); + self.close_workspace_then(AppMsg::ForceDisconnect, sender); } pub(super) fn do_disconnect(&mut self, sender: ComponentSender) { @@ -752,7 +751,7 @@ impl App { return; } if self.context.active.is_connected() { - self.confirm_teardown(Teardown::Switch, AppMsg::OpenSavedConfirmed(saved), sender); + self.close_workspace_then(AppMsg::OpenSavedConfirmed(saved), sender); return; } self.connect_saved(saved, sender); diff --git a/linux/crates/app/src/ui/app/import.rs b/linux/crates/app/src/ui/app/import.rs index 8faf29f417..bfd7c1d009 100644 --- a/linux/crates/app/src/ui/app/import.rs +++ b/linux/crates/app/src/ui/app/import.rs @@ -14,8 +14,8 @@ use tablepro_session::{OperationError, OperationKind, Session}; use crate::ui::file_read::{FileReadError, read_capped_file}; use crate::ui::import_dialog::{CsvImport, MAX_CSV_FILE_BYTES}; -use super::ran_statements::RanStatements; use super::{App, AppMsg}; +use crate::workspace::RanStatements; /// How many of an import's statements go into the log line that /// announces it, so a failed import has something to read back. diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index a55f073171..c531a90a02 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -1,12 +1,13 @@ +mod close_flow; mod connect_flow; mod connection; mod import; -mod ran_statements; mod row_ops; +mod saves; mod sidebar_section_header; mod status_pages; mod structure; -mod teardown_guard; +mod window_tabs; mod workspace_persistence; mod workspace_tabs; @@ -16,7 +17,7 @@ use relm4::gtk::{gio, glib}; use relm4::prelude::*; use relm4::{Controller, adw, gtk}; -use tablepro_core::{ColumnInfo, QueryResult}; +use tablepro_core::QueryResult; use tablepro_session::{ObjectListSnapshot, SessionHealth}; use tablepro_storage::SavedConnection; use uuid::Uuid; @@ -35,25 +36,6 @@ use crate::services::structure_tracker_registry::StructureTrackerRegistry; pub(crate) use connection::{ConnectRetry, ReadySession}; -/// Decrement a tab's pending-save counter in the close-after-save map. -/// Returns `true` if the entry just dropped to zero (the caller should -/// fire `WorkspaceTabClosed`); returns `false` if there's still another -/// in-flight save for that tab, or if the entry was never present. -/// -/// Used by both browse `SaveCompletedForTab` and structure -/// `on_structure_save_completed` so a Table tab with both kinds of -/// pending changes only closes after BOTH saves succeed. -pub(super) fn dec_close_after_save(map: &mut std::collections::HashMap, tab_id: &Uuid) -> bool { - if let Some(count) = map.get_mut(tab_id) { - *count = count.saturating_sub(1); - if *count == 0 { - map.remove(tab_id); - return true; - } - } - false -} - /// What `run()` hands the root component: the session services and the /// settings it opened before GTK started. pub struct AppInit { @@ -124,6 +106,18 @@ pub struct App { change_trackers: std::rc::Rc, /// This window's pending DDL, one tracker per Structure tab. structure_trackers: std::rc::Rc, + /// Starts, stops and settles this window's saves, and says what + /// closing any part of it would lose. + workspace_handle: crate::workspace::WorkspaceHandle, + /// The tabs as the close prompts read them. + window_tabs: std::rc::Rc, + /// One close prompt at a time, for a tab, the window, a disconnect + /// or a switch. + close_guards: std::rc::Rc, + /// What closing the window still owes its files and connections. + window_shutdown: std::rc::Rc, + /// Holds logout back while something here would be lost. + inhibitor: crate::workspace::WindowInhibitor, dialog: Option, history_dialog: Option>, saved_queries_dialog: Option>, @@ -138,30 +132,6 @@ pub struct App { /// Per-tab page size lives on each BrowseTab. saved_connections: Vec, connected: bool, - /// Tabs that close once their saves land, with the number still to - /// land: a Table tab with both row and DDL edits waits on two. A - /// failed save drops the entry, so the tab stays open to fix it. - close_after_save: std::rc::Rc>>, - /// Set when the user picked "Save" on the *window*-close dialog. - /// While true, the last `SaveCompletedForTab` that empties - /// `close_after_save` triggers `window.close()`. A `SaveFailed` - /// while in this state aborts the window-close intent. `Rc` - /// so the close-request handler closure can mutate it from outside - /// `App::update`. - close_window_after_save: std::rc::Rc>, - /// Count of in-flight transaction commits (a tab clicked Save - /// and is awaiting `execute_in_transaction`). Incremented by - /// `on_execute_browse_transaction` at dispatch, decremented by - /// `SaveCompletedForTab` and `SaveFailedForTab`. Window-close - /// blocks while this is > 0 so an async transaction never commits - /// after the tab / window has been torn down. - in_flight_saves: std::rc::Rc>, - /// Structure tabs currently mid-DDL-transaction. A second Ctrl+S - /// while a Save is still in flight would dispatch a parallel - /// transaction and potentially commit twice; this set lets the - /// dispatch path short-circuit. Cleared on - /// `StructureSaveCompleted` / `StructureSaveFailed`. - structure_saves_in_flight: std::rc::Rc>>, /// Writes the tab list for the session this window shows. Tabs fire /// `WorkspaceTabsChanged` on every selection, drag-reorder, /// page-size change and state-changed event, and it writes once @@ -395,6 +365,11 @@ pub enum AppMsg { /// Tear the connection down, once nothing would be lost or the user /// agreed to lose it. ForceDisconnect, + /// The close prompts decided about one tab's close. + TabCloseDecided { + id: Uuid, + approved: bool, + }, RefreshPage, ShowAbout, ShowPreferences, @@ -422,34 +397,18 @@ pub enum AppMsg { /// The tab order, the selection or a tab's state changed, so the /// workspace is saved. WorkspaceTabsChanged, - /// Save a BrowseTab's pending changes as one batch, planned on the - /// connection that runs it. App dispatches - /// `BrowseTabInput::SaveCompleted` / `SaveFailed` back via the - /// per-tab controller. - ExecuteBrowseTransaction { - tab_id: Uuid, - table: tablepro_core::meta::TableRef, - columns: Vec, - changes: crate::services::change_tracker::TrackedChanges, - }, - /// Inline-Save resolved successfully for a specific browse tab. - /// Routes through App.update so we can reset the row-op spinner - /// before forwarding `BrowseTabInput::SaveCompleted` to the tab. - /// A row another session changed fails the save's row guard, so a - /// completed save touched exactly the rows it named. - SaveCompletedForTab(Uuid), - /// Inline-Save failed; transaction was already rolled back. - SaveFailedForTab(Uuid, String), - /// Driver reported `DriverError::Transaction { statement_index }`. - /// Routed before SaveFailedForTab so the tab can scroll-and-select - /// the offending row before the error alert appears. - FlashErrorRowForTab(Uuid, crate::services::change_tracker::StatementSource), - /// Ctrl+S, for the active Table tab. - SaveActiveBrowseTab, - /// Targeted variant: close-confirmation dialogs use this to commit - /// a specific tab (which may not be the currently-active one when - /// the user is closing a background tab via its X button). - SaveActiveBrowseTabById(Uuid), + /// A tab's own Save: write what its tracker holds. + SaveTab(Uuid), + /// A tab's Stop Saving. + StopSaving(Uuid), + /// A save started or settled, or tabs' changes were dropped. + Workspace(crate::workspace::WorkspaceEvent), + /// The number of saves running in this window changed. + SavesInFlightChanged(usize), + /// An editor tab's transaction opened or ended. + EditorTransactionChanged, + /// Ctrl+S, for the active tab. + SaveActiveTab, /// Ctrl+Z, for the active Table tab. UndoActiveBrowseTab, /// Ctrl+Y, for the active Table tab. @@ -503,17 +462,6 @@ pub enum AppMsg { }, /// The table is gone, so its tabs close without asking. DropTableSucceeded(FromSession), - /// Save one Structure tab, which need not be the active one. - SaveActiveStructureTabById(Uuid), - /// Structure tab Save resolved successfully. `new_table_name` is - /// `Some(name)` for `New` mode CreateTable transitions; the tab - /// promotes to Edit mode and the slot's `table` field updates. - StructureSaveCompleted { - tab_id: Uuid, - new_table_name: Option, - }, - /// Structure tab Save failed; tracker is intact for retry. - StructureSaveFailed(Uuid, String), /// Tracker for a Structure tab crossed empty / non-empty boundary. /// Mirrors `BrowseTabDirtyChanged` for the title prefix and the /// AdwTabPage::set_needs_attention background-tab indicator. @@ -723,164 +671,45 @@ impl SimpleComponent for App { } crate::ui::window_geometry::restore(&widgets.window, &settings); - // Window-close handler. Three responsibilities: persist window - // size + maximize state, intercept close when any tab has - // unsaved edits with a Cancel | Discard | Save dialog, and - // route Save through the same SaveCompletedForTab plumbing as - // a per-tab close so failures abort cleanly. - let settings_for_close = settings.clone(); - let storage_for_close = storage.clone(); - let drafts_for_close = drafts.clone(); - let workspace_for_close = workspace.clone(); - let force_close: std::rc::Rc> = std::rc::Rc::new(std::cell::Cell::new(false)); - let force_close_for_close = force_close.clone(); - let close_after_save_for_close: std::rc::Rc>> = - std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashMap::new())); - let close_window_after_save_for_close: std::rc::Rc> = - std::rc::Rc::new(std::cell::Cell::new(false)); - let in_flight_saves: std::rc::Rc> = std::rc::Rc::new(std::cell::Cell::new(0)); - let close_after_save_handle = close_after_save_for_close.clone(); - let close_window_after_save_handle = close_window_after_save_for_close.clone(); - let in_flight_saves_handle = in_flight_saves.clone(); - let in_flight_saves_for_close = in_flight_saves.clone(); let change_trackers = std::rc::Rc::new(ChangeTrackerRegistry::default()); let structure_trackers = std::rc::Rc::new(StructureTrackerRegistry::default()); - let change_trackers_for_close = change_trackers.clone(); - let structure_trackers_for_close = structure_trackers.clone(); - let close_request_input_sender = sender.input_sender().clone(); let connect_attempts = std::rc::Rc::new(tablepro_session::ConnectAttempts::new()); - let context_for_close = context.clone(); - let connect_attempts_for_close = connect_attempts.clone(); - widgets.window.connect_close_request(move |w| { - // A save still running would commit after its tab is gone, - // with nothing left to say whether it worked. - if !force_close_for_close.get() && in_flight_saves_for_close.get() > 0 { - let dialog = adw::AlertDialog::new( - Some(&crate::i18n::gettext("Saving in progress")), - Some(&crate::i18n::gettext( - "Waiting for pending saves to finish before closing the window.", - )), - ); - dialog.set_can_close(false); - dialog.present(Some(w)); - let dialog_for_poll = dialog.clone(); - let window_for_poll = w.clone(); - let force_close_for_poll = force_close_for_close.clone(); - let in_flight_for_poll = in_flight_saves_for_close.clone(); - glib::timeout_add_local(std::time::Duration::from_millis(100), move || { - if in_flight_for_poll.get() == 0 { - dialog_for_poll.close(); - force_close_for_poll.set(true); - window_for_poll.close(); - glib::ControlFlow::Break - } else { - glib::ControlFlow::Continue - } - }); - return glib::Propagation::Stop; - } - let has_pending = !dirty_tabs(&change_trackers_for_close, &structure_trackers_for_close).is_empty(); - if !force_close_for_close.get() && has_pending { - let dialog = adw::AlertDialog::new(None, None); - dialog.set_heading(Some(&crate::i18n::gettext("Save changes before closing?"))); - dialog.set_body(&crate::i18n::gettext( - "One or more tabs have unsaved changes. They will be permanently lost if you discard them.", - )); - dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); - dialog.add_response("discard", &crate::i18n::gettext("Discard")); - dialog.add_response("save", &crate::i18n::gettext("Save")); - dialog.set_response_appearance("discard", adw::ResponseAppearance::Destructive); - dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("save")); - dialog.set_close_response("cancel"); - let force_close_for_resp = force_close_for_close.clone(); - let window_for_resp = w.clone(); - let close_after_save_for_resp = close_after_save_for_close.clone(); - let close_window_after_save_for_resp = close_window_after_save_for_close.clone(); - let input_sender_for_resp = close_request_input_sender.clone(); - let change_trackers_for_resp = change_trackers_for_close.clone(); - let structure_trackers_for_resp = structure_trackers_for_close.clone(); - dialog.connect_response(None, move |dlg, response| { - dlg.close(); - match response { - "discard" => { - // The window closes next, so no grid is left - // to show the discarded rows. - for tab_id in change_trackers_for_resp.dirty() { - if let Some(tracker) = change_trackers_for_resp.get(tab_id) { - let _ = tracker.discard(); - } - } - for tab_id in structure_trackers_for_resp.dirty() { - if let Some(tracker) = structure_trackers_for_resp.get(tab_id) { - tracker.clear(); - } - } - force_close_for_resp.set(true); - window_for_resp.close(); - } - "save" => { - // close_after_save tracks both kinds of save; the - // SaveCompletedForTab / StructureSaveCompleted - // handlers in App::update close the window once - // the set drains. Any SaveFailed aborts. - let browse_tabs = change_trackers_for_resp.dirty(); - let structure_tabs = structure_trackers_for_resp.dirty(); - // A Table tab in both sets waits for both saves. - { - let mut map = close_after_save_for_resp.borrow_mut(); - for id in browse_tabs.iter().copied() { - *map.entry(id).or_insert(0) += 1; - } - for id in structure_tabs.iter().copied() { - *map.entry(id).or_insert(0) += 1; - } - } - close_window_after_save_for_resp.set(true); - for id in browse_tabs { - let _ = input_sender_for_resp.send(AppMsg::SaveActiveBrowseTabById(id)); - } - for id in structure_tabs { - let _ = input_sender_for_resp.send(AppMsg::SaveActiveStructureTabById(id)); - } - } - _ => {} // Cancel: do nothing, stay open. - } - }); - dialog.present(Some(w)); - return glib::Propagation::Stop; - } - crate::ui::window_geometry::persist(w, &settings_for_close); - connection::close_window_session( - &context_for_close.active, - &*context_for_close.hold, - &connect_attempts_for_close, - ); - // The last column drag or filter change may still be on its - // way to disk. The hold keeps the process alive until it - // lands, so quitting does not lose it. - let hold = w - .application() - .map(|app| gio::prelude::ApplicationExtManual::hold(&app)); - let column_widths = storage_for_close.column_widths().flush(); - let filter_settings = storage_for_close.filter_settings().flush(); - let editor_drafts = drafts_for_close.flush_all(); - let tabs = workspace_for_close.detach(); - glib::spawn_future_local(async move { - column_widths.await; - filter_settings.await; - for failure in editor_drafts.await { - tracing::warn!(failure, "an editor draft was not saved"); - } - if let Some(tabs) = tabs - && let Err(error) = tabs.await - { - tracing::debug!(%error, "the open tabs were not saved"); - } - drop(hold); - }); - glib::Propagation::Proceed + let workspace_tabs: workspace_persistence::WorkspaceTabs = + std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashMap::new())); + let window_tabs = std::rc::Rc::new(window_tabs::WindowTabs::new(workspace_tabs.clone())); + let events_sender = sender.input_sender().clone(); + let workspace_handle = crate::workspace::WorkspaceHandle::new(crate::workspace::WorkspaceDeps { + session: context.active.clone(), + tasks: context.tasks.clone(), + hold: context.hold.clone(), + history: Some(history.clone()), + rows: change_trackers.clone(), + structures: structure_trackers.clone(), + directory: window_tabs.clone(), + events: std::rc::Rc::new(move |event| events_sender.emit(AppMsg::Workspace(event))), }); + let saves_sender = sender.input_sender().clone(); + workspace_handle + .ledger() + .on_change(move |count| saves_sender.emit(AppMsg::SavesInFlightChanged(count))); + let close_guards = close_flow::CloseGuards::new(); + let window_shutdown = close_flow::WindowShutdown::new(close_flow::WindowShutdownParts { + settings: settings.clone(), + storage: storage.clone(), + drafts: drafts.clone(), + workspace: workspace.clone(), + tabs: workspace_tabs.clone(), + context: context.clone(), + connect_attempts: connect_attempts.clone(), + }); + close_flow::connect_window_close( + &widgets.window, + close_guards.clone(), + workspace_handle.clone(), + window_shutdown.clone(), + ); + let inhibitor = + crate::workspace::WindowInhibitor::new(crate::workspace::ApplicationInhibit::new(&widgets.window)); let breakpoint = adw::Breakpoint::new(adw::BreakpointCondition::new_length( adw::BreakpointConditionLengthType::MaxWidth, @@ -1161,9 +990,14 @@ impl SimpleComponent for App { workspace_root: None, workspace_tab_view: None, workspace_root_added: std::cell::Cell::new(false), - workspace_tabs: std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashMap::new())), + workspace_tabs, change_trackers, structure_trackers, + workspace_handle, + window_tabs, + close_guards, + window_shutdown, + inhibitor, dialog: None, history_dialog: None, saved_queries_dialog: None, @@ -1173,10 +1007,6 @@ impl SimpleComponent for App { read_only: false, saved_connections: Vec::new(), connected: false, - close_after_save: close_after_save_handle, - close_window_after_save: close_window_after_save_handle, - in_flight_saves: in_flight_saves_handle, - structure_saves_in_flight: std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashSet::new())), workspace, closed_tabs_stack: std::rc::Rc::new(std::cell::RefCell::new(std::collections::VecDeque::with_capacity( CLOSED_TABS_CAPACITY, @@ -1213,10 +1043,12 @@ impl SimpleComponent for App { // the window does. self.history_gate.abort(); self.prompt_presenter.abort(); - self.session_links = None; // The close request already did this; a window destroyed - // without one still stops its session's work here. - connection::close_window_session(&self.context.active, &*self.context.hold, &self.connect_attempts); + // without one still closes its tabs' connections, then its own, + // here. The claim on the session's key goes last, so no other + // window adopts a connection still closing. + self.window_shutdown.run(None); + self.session_links = None; } fn update(&mut self, msg: Self::Input, sender: ComponentSender) { @@ -1245,6 +1077,7 @@ impl SimpleComponent for App { } AppMsg::Disconnect => self.on_disconnect(sender), AppMsg::ForceDisconnect => self.do_disconnect(sender), + AppMsg::TabCloseDecided { id, approved } => self.on_tab_close_decided(id, approved), AppMsg::DialogClosed => self.dialog = None, AppMsg::SelectTable { schema, @@ -1252,62 +1085,16 @@ impl SimpleComponent for App { open_mode, } => self.dispatch_select_table(schema, name, open_mode, sender), AppMsg::WorkspaceTabsChanged => self.on_workspace_tabs_changed(), - AppMsg::ExecuteBrowseTransaction { - tab_id, - table, - columns, - changes, - } => { - self.on_execute_browse_transaction(tab_id, table, columns, changes, sender); - } - AppMsg::SaveCompletedForTab(tab_id) => { - self.set_row_op_in_flight(false); - self.in_flight_saves.set(self.in_flight_saves.get().saturating_sub(1)); - // No Undo button: the save has already committed, and - // Ctrl+Z is for edits before Save. A shorter timeout - // keeps the confirmation from outstaying the moment. - let toast = adw::Toast::builder() - .title(crate::i18n::gettext("Saved")) - .timeout(4) - .build(); - self.toast_overlay.add_toast(toast); - self.dispatch_to_tab(tab_id, BrowseTabInput::SaveCompleted); - // If the user picked Save in a close-confirmation - // dialog, fire the close now that the commit succeeded. - // The counter ensures a tab with BOTH browse-dirty and - // structure-dirty waits for both saves before closing. - let drained = dec_close_after_save(&mut self.close_after_save.borrow_mut(), &tab_id); - if drained { - sender.input(AppMsg::WorkspaceTabClosed(tab_id)); - } - // If we're in a window-close-Save-all flow and the map - // just drained, the window can finally close. - if self.close_window_after_save.get() && self.close_after_save.borrow().is_empty() { - self.close_window_after_save.set(false); - self.window.close(); + AppMsg::SaveTab(id) => self.request_save(id), + AppMsg::StopSaving(id) => self.stop_saving(id), + AppMsg::Workspace(event) => self.on_workspace_event(event, sender), + AppMsg::SavesInFlightChanged(count) => self.on_saves_in_flight_changed(count), + AppMsg::EditorTransactionChanged => self.refresh_inhibitor(), + AppMsg::SaveActiveTab => { + if let Some(id) = self.selected_workspace_tab_id() { + self.request_save(id); } } - AppMsg::SaveFailedForTab(tab_id, message) => { - self.set_row_op_in_flight(false); - self.in_flight_saves.set(self.in_flight_saves.get().saturating_sub(1)); - // Abort any close-after-save intent: the commit failed, - // so we keep the tab open and let the user see the - // error and retry. Window-close intent is also cleared. - self.close_after_save.borrow_mut().remove(&tab_id); - self.close_window_after_save.set(false); - self.dispatch_to_tab(tab_id, BrowseTabInput::SaveFailed(message)); - } - AppMsg::FlashErrorRowForTab(tab_id, source) => { - self.dispatch_to_tab(tab_id, BrowseTabInput::FlashErrorRow(source)); - } - AppMsg::SaveActiveBrowseTab => { - if let Some(id) = self.selected_browse_tab_id() { - self.dispatch_to_tab(id, BrowseTabInput::CommitSave); - } - } - AppMsg::SaveActiveBrowseTabById(id) => { - self.dispatch_to_tab(id, BrowseTabInput::CommitSave); - } AppMsg::UndoActiveBrowseTab => { // Ctrl+Z routes to BrowseTab undo only. Structure // editing follows the snapshot+diff model: DDL undo @@ -1329,7 +1116,10 @@ impl SimpleComponent for App { AppMsg::CloseActiveWorkspaceTab => self.close_active_workspace_tab(sender), AppMsg::ShowAlert { title, body } => self.show_error_alert(&title, &body), AppMsg::ShowToast(msg) => self.show_toast(&msg), - AppMsg::BrowseTabDirtyChanged(tab_id, dirty) => self.refresh_browse_tab_dirty(tab_id, dirty), + AppMsg::BrowseTabDirtyChanged(tab_id, dirty) => { + self.refresh_browse_tab_dirty(tab_id, dirty); + self.refresh_inhibitor(); + } AppMsg::NewTableTab { schema } => self.on_new_table_tab(schema, sender), AppMsg::EditStructureTab { schema, table } => self.on_edit_structure_tab(schema, table, sender), AppMsg::ShowCreateTableForExisting { schema, table } => self.on_show_create_table(schema, table, sender), @@ -1345,12 +1135,10 @@ impl SimpleComponent for App { self.on_drop_table_succeeded(table, sender); } } - AppMsg::SaveActiveStructureTabById(id) => self.save_structure_tab_by_id(id, sender), - AppMsg::StructureSaveCompleted { tab_id, new_table_name } => { - self.on_structure_save_completed(tab_id, new_table_name, sender) + AppMsg::StructureTabDirtyChanged(tab_id, dirty) => { + self.refresh_structure_tab_dirty(tab_id, dirty); + self.refresh_inhibitor(); } - AppMsg::StructureSaveFailed(tab_id, message) => self.on_structure_save_failed(tab_id, message), - AppMsg::StructureTabDirtyChanged(tab_id, dirty) => self.refresh_structure_tab_dirty(tab_id, dirty), AppMsg::SchemaChanged { schema, table } => self.on_schema_changed(schema, table), AppMsg::ReloadConnections => self.on_reload_connections(sender), AppMsg::ConnectionListUnavailable => self.show_connection_list_banner(), @@ -1539,7 +1327,7 @@ fn install_window_actions(window: &adw::ApplicationWindow, sender: ComponentSend input_action!("close-current", AppMsg::CloseActiveWorkspaceTab), input_action!("preferences", AppMsg::ShowPreferences), input_action!("refresh-page", AppMsg::RefreshPage), - input_action!("save-changes", AppMsg::SaveActiveBrowseTab), + input_action!("save-changes", AppMsg::SaveActiveTab), input_action!("undo-change", AppMsg::UndoActiveBrowseTab), input_action!("redo-change", AppMsg::RedoActiveBrowseTab), input_action!("reopen-closed-tab", AppMsg::ReopenClosedTab), diff --git a/linux/crates/app/src/ui/app/row_ops.rs b/linux/crates/app/src/ui/app/row_ops.rs index 5c68af56f5..6e60ae697b 100644 --- a/linux/crates/app/src/ui/app/row_ops.rs +++ b/linux/crates/app/src/ui/app/row_ops.rs @@ -1,125 +1,9 @@ -use relm4::ComponentSender; use relm4::adw::prelude::*; - -use tablepro_core::meta::TableRef; -use tablepro_core::save::{SavePlan, plan_save}; -use tablepro_core::{ColumnInfo, DriverError, WriteKind}; -use tablepro_session::runtime::Tasks; -use tablepro_session::{OperationError, OperationKind}; use uuid::Uuid; -use crate::services::change_tracker::{StatementSource, TrackedChanges, pk_identity}; -use crate::ui::browse_tab::BrowseTabInput; -use crate::ui::error_text; - -use super::ran_statements::RanStatements; -use super::{App, AppMsg}; +use super::App; impl App { - /// Save a browse tab's pending changes as one batch. - /// - /// The plan is built inside the operation from the connection it - /// runs on, so the SQL matches that server even after a reconnect. - /// Every update and delete carries a row guard: a row another - /// session changed or removed fails the whole save rather than - /// passing as a save that touched nothing. - pub(super) fn on_execute_browse_transaction( - &self, - tab_id: Uuid, - table: TableRef, - columns: Vec, - changes: TrackedChanges, - sender: ComponentSender, - ) { - let Some(session) = self.context.active.get() else { - self.dispatch_to_tab( - tab_id, - BrowseTabInput::SaveFailed(crate::i18n::gettext("No active connection")), - ); - return; - }; - self.set_row_op_in_flight(true); - // Window-close waits while this is above zero, so a save never - // commits after its tab is gone. SaveCompletedForTab and - // SaveFailedForTab bring it back down whatever the outcome. - self.in_flight_saves.set(self.in_flight_saves.get() + 1); - let history = self.history.store(); - let connection = self.context.active.metadata(); - let work = async move { - let changes_for_plan = changes.changes.clone(); - let saved = session - .run(OperationKind::Save(WriteKind::RowChanges), move |lease, options| { - let table = table.clone(); - let columns = columns.clone(); - let changes = changes_for_plan.clone(); - async move { - let plan = plan_save(&*lease.dialect(), &table, &columns, &pk_identity(&columns), &changes)?; - // A row save changes the database, so it belongs - // in the same history as a statement run from the - // editor. - let sql: Vec = plan - .batch - .steps - .iter() - .map(|step| step.statement.sql().to_owned()) - .collect(); - let outcome = lease.write_batch(&plan.batch, options).await; - Ok((plan, sql, outcome)) - } - }) - .await; - (saved, changes) - }; - let tasks = self.context.tasks.clone(); - let history_tasks = tasks.clone(); - let _delivery = crate::runtime::spawn_and_deliver(&tasks, work, move |finished| { - let (saved, changes) = match finished { - Ok(finished) => finished, - Err(failure) => { - tracing::warn!(%failure, "the save task did not finish"); - sender.input(AppMsg::SaveFailedForTab( - tab_id, - crate::i18n::gettext("The save did not finish."), - )); - return; - } - }; - let (plan, sql, outcome) = match saved { - Ok(planned) => planned, - Err(error) => { - sender.input(AppMsg::SaveFailedForTab( - tab_id, - error_text::operation_error_message(&error), - )); - return; - } - }; - let ran = RanStatements::starting(history, connection, sql); - match outcome { - Ok(report) => { - let touched = report.total_rows(); - record( - &history_tasks, - ran, - touched.and_then(|rows| i64::try_from(rows).ok()), - None, - ); - sender.input(AppMsg::SaveCompletedForTab(tab_id)); - } - Err(error) => { - // The row behind the failing step is flashed before - // the error shows, so the user sees which one it was. - if let Some(source) = failing_source(&plan, &changes, &error) { - sender.input(AppMsg::FlashErrorRowForTab(tab_id, source)); - } - let message = error_text::operation_error_message(&OperationError::Driver(error)); - record(&history_tasks, ran, None, Some(message.clone())); - sender.input(AppMsg::SaveFailedForTab(tab_id, message)); - } - } - }); - } - pub(super) fn set_row_op_in_flight(&self, in_flight: bool) { self.row_op_spinner.set_visible(in_flight); } @@ -156,123 +40,3 @@ impl App { self.show_toast(&crate::i18n::gettext("Copied to clipboard")); } } - -/// The grid row behind the statement a save failed at, when the -/// engine said which one it was. -fn failing_source(plan: &SavePlan, changes: &TrackedChanges, error: &DriverError) -> Option { - let step = match error { - DriverError::RolledBack { statement_index, .. } - | DriverError::RowGuardFailed { statement_index, .. } - | DriverError::GuardUnsupported { statement_index } - | DriverError::PartiallyApplied { statement_index, .. } => *statement_index, - _ => return None, - }; - changes.source(plan.origin(step)?) -} - -/// Record a save in the history. The history database needs the -/// runtime, and a history that cannot be written is worth a log line, -/// never a lost edit, so nothing waits for it. -fn record(tasks: &Tasks, ran: RanStatements, rows_affected: Option, failure: Option) { - let outcome = match failure { - None => tablepro_storage::query_history::Outcome::Success, - Some(message) => tablepro_storage::query_history::Outcome::Error(message), - }; - drop(tasks.spawn_task(ran.finished(rows_affected, outcome))); -} - -#[cfg(test)] -mod tests { - use tablepro_core::Value; - use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType, IntegerKind, IntegerWidth}; - - use super::*; - use crate::services::change_tracker::RowKey; - use crate::services::tab_tracker_handle::TabTrackerHandle; - use crate::services::tracker_edit::TrackerEdit; - - fn column(name: &str, primary_key: bool) -> ColumnInfo { - let mut column_type = ColumnType::untyped_text("integer"); - column_type.kind = ColumnKind::Integer(IntegerKind { - width: IntegerWidth::W32, - signed: true, - }); - ColumnInfo { - name: name.to_owned(), - column_type, - nullable: false, - primary_key, - is_auto_increment: false, - is_generated: false, - default: ColumnDefault::None, - comment: None, - } - } - - fn planned() -> (SavePlan, TrackedChanges, RowKey) { - let columns = vec![column("id", true), column("n", false)]; - let edited = RowKey::from_pk_values(&[Value::Int(3)]).unwrap(); - let tracker = TabTrackerHandle::default(); - let _ = tracker.apply(TrackerEdit::Insert { - values: vec![Value::Int(10), Value::Int(1)], - }); - let _ = tracker.apply(TrackerEdit::Cell { - row_key: edited.clone(), - col: 1, - original: Value::Int(1), - value: Value::Int(2), - }); - let changes = tracker.change_set(&columns).unwrap(); - let dialect = drivers_sqlite::SqliteDialect::new(drivers_sqlite::SqliteVersion::new(3, 45, 0)); - let plan = plan_save( - &dialect, - &TableRef::unqualified("t"), - &columns, - &pk_identity(&columns), - &changes.changes, - ) - .unwrap(); - (plan, changes, edited) - } - - #[test] - fn a_guard_failure_flashes_the_row_it_was_about() { - let (plan, changes, edited) = planned(); - let error = DriverError::RowGuardFailed { - statement_index: 1, - expected: 1, - actual: 0, - }; - - let source = failing_source(&plan, &changes, &error); - - assert!(matches!(source, Some(StatementSource::Update { row_key }) if row_key == edited)); - } - - #[test] - fn a_rolled_back_insert_flashes_its_draft() { - let (plan, changes, _) = planned(); - let error = DriverError::RolledBack { - statement_index: 0, - source: Box::new(DriverError::server("duplicate key")), - }; - - assert!(matches!( - failing_source(&plan, &changes, &error), - Some(StatementSource::Insert { .. }) - )); - } - - #[test] - fn an_error_that_names_no_statement_flashes_nothing() { - let (plan, changes, _) = planned(); - - assert!(failing_source(&plan, &changes, &DriverError::Busy).is_none()); - let past_the_end = DriverError::RowGuardFailed { - statement_index: 7, - expected: 1, - actual: 0, - }; - assert!(failing_source(&plan, &changes, &past_the_end).is_none()); - } -} diff --git a/linux/crates/app/src/ui/app/saves.rs b/linux/crates/app/src/ui/app/saves.rs new file mode 100644 index 0000000000..a67f2718dd --- /dev/null +++ b/linux/crates/app/src/ui/app/saves.rs @@ -0,0 +1,118 @@ +use relm4::{ComponentSender, adw}; +use uuid::Uuid; + +use tablepro_core::WriteKind; + +use super::{App, WorkspaceTab}; +use crate::ui::browse_tab::BrowseTabInput; +use crate::ui::structure_tab::StructureTabInput; +use crate::workspace::{CloseScope, DirtyTab, SaveOutcome, SaveSettled, SaveStart, WorkspaceEvent}; + +impl App { + /// Save one tab, from its own Save button or Ctrl+S. + pub(super) fn request_save(&self, tab: Uuid) { + for start in self.workspace_handle.save(&[tab]) { + match start { + SaveStart::Started(save) => tracing::debug!(?save, %tab, "save started"), + SaveStart::NothingToSave => {} + SaveStart::Rejected(rejection) => { + self.show_error_alert(&crate::i18n::gettext("Cannot Save"), &rejection.message()); + } + } + } + } + + pub(super) fn stop_saving(&self, tab: Uuid) { + self.workspace_handle.stop_saves(CloseScope::Tab(tab)); + } + + pub(super) fn on_workspace_event(&mut self, event: WorkspaceEvent, sender: ComponentSender) { + match event { + WorkspaceEvent::SaveStarted { tab } => { + self.dispatch_to_tab(tab, BrowseTabInput::SaveStarted); + self.send_to_structure_tab(tab, StructureTabInput::SaveStarted); + } + WorkspaceEvent::SaveSettled(settled) => self.on_save_settled(*settled, sender), + WorkspaceEvent::Discarded { tabs, reload } => { + for tab in tabs { + let browse = match reload { + true => BrowseTabInput::DiscardAndRefresh, + false => BrowseTabInput::DiscardAll, + }; + self.dispatch_to_tab(tab, browse); + let structure = match reload { + true => StructureTabInput::Reload, + false => StructureTabInput::Discard, + }; + self.send_to_structure_tab(tab, structure); + } + } + } + self.refresh_inhibitor(); + } + + pub(super) fn on_saves_in_flight_changed(&mut self, count: usize) { + self.set_row_op_in_flight(count > 0); + self.refresh_inhibitor(); + } + + fn on_save_settled(&mut self, settled: SaveSettled, sender: ComponentSender) { + let SaveSettled { + tab, + kind, + outcome, + failing, + saved_table_name, + } = settled; + match (kind, outcome) { + (WriteKind::RowChanges, SaveOutcome::Committed { .. }) => { + // No Undo button: the save has already committed, and + // Ctrl+Z is for edits before Save. A shorter timeout + // keeps the confirmation from outstaying the moment. + let toast = adw::Toast::builder() + .title(crate::i18n::gettext("Saved")) + .timeout(4) + .build(); + self.toast_overlay.add_toast(toast); + self.dispatch_to_tab(tab, BrowseTabInput::SaveCommitted); + } + (WriteKind::RowChanges, SaveOutcome::RolledBack(cause)) => { + // The row behind the failing step is flashed before the + // error shows, so the user sees which one it was. + if let Some(source) = failing { + self.dispatch_to_tab(tab, BrowseTabInput::FlashErrorRow(source)); + } + self.dispatch_to_tab(tab, BrowseTabInput::SaveFailed(cause.message())); + } + (WriteKind::RowChanges, SaveOutcome::Unknown(cause)) => { + let message = cause.sentence(&self.tab_name(tab)); + self.dispatch_to_tab(tab, BrowseTabInput::SaveOutcomeUnknown(message)); + } + (WriteKind::SchemaChanges, SaveOutcome::Committed { .. }) => { + self.on_structure_save_completed(tab, saved_table_name, sender); + } + (WriteKind::SchemaChanges, SaveOutcome::RolledBack(cause)) => { + self.send_to_structure_tab(tab, StructureTabInput::SaveFailed(cause.message())); + } + (WriteKind::SchemaChanges, SaveOutcome::Unknown(cause)) => { + let message = cause.sentence(&self.tab_name(tab)); + self.send_to_structure_tab(tab, StructureTabInput::SaveOutcomeUnknown(message)); + } + } + } + + /// The tab's name for a sentence, quoted as the close prompts quote + /// it. + fn tab_name(&self, tab: Uuid) -> String { + let title = self.workspace_tabs.borrow().get(&tab).map(|slot| { + let page = match slot { + WorkspaceTab::Editor(slot) => &slot.page, + WorkspaceTab::Structure(slot) => &slot.page, + WorkspaceTab::Table(slot) => &slot.page, + }; + let title = page.title(); + title.strip_prefix("• ").unwrap_or(&title).to_owned() + }); + DirtyTab::quote(&title.unwrap_or_default()) + } +} diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index ea760a81be..141c876804 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -1,12 +1,6 @@ -//! App-side handlers for the Structure (DDL) workspace tab. -//! -//! Each `on_*` method mirrors the equivalent Browse-tab path in -//! `browse.rs` / `row_ops.rs`: dispatch by tab id, take a slot -//! reference, run the work through the session on the app runtime, -//! route results back through `AppMsg::Structure*Loaded` / -//! `Structure*Completed` variants. Window-close is gated by the shared -//! `in_flight_saves` counter so a Structure DDL write can't commit -//! after the window has been torn down. +//! App-side handlers for the Structure (DDL) workspace tab: opening +//! one, dropping a table, and following a save that landed. The save +//! itself runs through the workspace handle, like a row save. use std::sync::Arc; @@ -14,19 +8,18 @@ use relm4::adw::prelude::*; use relm4::{ComponentSender, adw}; use uuid::Uuid; -use tablepro_core::ddl::{BuildDdlError, DdlDialect, DdlOperation, DdlPlan, DropTableOptions, TableDefinition}; +use tablepro_core::ddl::{BuildDdlError, DdlOperation, DropTableOptions, TableDefinition}; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{ObjectScope, TableRef}; -use tablepro_core::statement::SqlBuildError; -use tablepro_core::{WriteKind, WriteReport}; use tablepro_session::runtime::TaskFailure; use tablepro_session::{OperationError, OperationKind, Session}; +use tokio_util::sync::CancellationToken; use crate::services::from_session::FromSession; -use crate::services::pending_structure::PendingStructure; use crate::ui::app::{App, AppMsg, WorkspaceTab}; use crate::ui::error_text; -use crate::ui::structure_tab::{StructureMode, StructureTabInput, save_blocker_text}; +use crate::ui::structure_tab::{StructureMode, StructureTabInput}; +use crate::workspace::run_schema_plan; impl App { /// Sidebar right-click → "New Table…" or schema-header "+" button. @@ -136,9 +129,17 @@ impl App { cascade: false, }; let origin = Arc::downgrade(&session); - let work = run_schema_plan(session, dropped.clone(), DdlOperation::DropTable, move |ddl, table| { - ddl.plan_drop_table(table, options) - }); + let table = dropped.clone(); + let work = async move { + run_schema_plan( + session, + table, + DdlOperation::DropTable, + &CancellationToken::new(), + move |ddl, table| ddl.plan_drop_table(table, options), + ) + .await + }; crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { match settled(result).and_then(|(_, written)| written) { Ok(_) => sender.input(AppMsg::DropTableSucceeded(FromSession::new(origin, dropped))), @@ -150,33 +151,6 @@ impl App { }); } - /// Save one Structure tab, whether or not it is the active tab: what - /// its tracker holds, planned with the connection that runs it. - pub(super) fn save_structure_tab_by_id(&mut self, tab_id: Uuid, sender: ComponentSender) { - // The save in flight settles `close_after_save` for this tab; - // a second one would return without ever completing. - if self.structure_saves_in_flight.borrow().contains(&tab_id) { - tracing::debug!( - ?tab_id, - "save_structure_tab_by_id: save already in flight; deferring to first dispatch" - ); - return; - } - let Some(tracker) = self.structure_trackers.get(tab_id) else { - return; - }; - match tracker.begin_save().map(|pending| (pending.check(), pending)) { - Some((Ok(()), pending)) => self.run_structure_save(tab_id, pending, sender), - Some((Err(blocker), _)) => self.refuse_structure_save(tab_id, save_blocker_text(&blocker)), - // Nothing to save, so a close waiting on this save can go - // ahead. - None => sender.input(AppMsg::StructureSaveCompleted { - tab_id, - new_table_name: None, - }), - } - } - pub(super) fn on_drop_table_succeeded(&mut self, table: TableRef, sender: ComponentSender) { self.close_tabs_for_table(table.schema.as_deref(), &table.name); sender.input(AppMsg::SchemaChanged { @@ -205,62 +179,6 @@ impl App { } } - /// Run a structure save as one schema write. The driver decides how - /// it is wrapped: a transaction where the engine's DDL is - /// transactional, statement by statement where it is not, and the - /// error says which of the two the user got. - fn run_structure_save(&mut self, tab_id: Uuid, pending: PendingStructure, sender: ComponentSender) { - // A second Ctrl+S, or a quick double-click on Save, while the - // first write is still running would commit the same statements - // twice. - if !self.structure_saves_in_flight.borrow_mut().insert(tab_id) { - tracing::debug!(?tab_id, "structure save already in flight; ignoring duplicate"); - return; - } - let Some(session) = self.context.active.get() else { - self.refuse_structure_save(tab_id, crate::i18n::gettext("No active connection.")); - return; - }; - // Only a Structure tab hosts the DDL editor, so a save for any - // other id is a stale queued message. - if !matches!( - self.workspace_tabs.borrow().get(&tab_id), - Some(WorkspaceTab::Structure(_)) - ) { - self.structure_saves_in_flight.borrow_mut().remove(&tab_id); - return; - } - let new_table_name = pending.saved_table_name(); - - self.in_flight_saves.set(self.in_flight_saves.get() + 1); - let history = self.history.store(); - let metadata = self.context.active.metadata(); - let work = async move { - let table = pending.table(); - let operation = pending.operation(); - let (statements, written) = - run_schema_plan(session, table, operation, move |ddl, _| pending.plan(ddl)).await?; - // Structure edits change the database too, so the history - // shows the DDL that did it and not only editor statements. - let ran = super::ran_statements::RanStatements::starting(history, metadata, statements); - let outcome = match &written { - Ok(_) => tablepro_storage::query_history::Outcome::Success, - Err(error) => { - tablepro_storage::query_history::Outcome::Error(error_text::operation_error_message(error)) - } - }; - ran.finished(None, outcome).await; - written - }; - crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| match settled(result) { - Ok(_) => sender.input(AppMsg::StructureSaveCompleted { tab_id, new_table_name }), - Err(error) => sender.input(AppMsg::StructureSaveFailed( - tab_id, - error_text::operation_error_message(&error), - )), - }); - } - /// Save resolved successfully. Two cases: /// /// 1. **New-Table draft**: CreateTable succeeded so the table now @@ -275,11 +193,6 @@ impl App { new_table_name: Option, sender: ComponentSender, ) { - if self.in_flight_saves.get() > 0 { - self.in_flight_saves.set(self.in_flight_saves.get() - 1); - } - self.structure_saves_in_flight.borrow_mut().remove(&tab_id); - // Decided first, so the tabs are not borrowed while they change. enum SaveKind { PromoteNewToTable(Option, String), @@ -305,7 +218,6 @@ impl App { } }; - let kind_promoted = matches!(kind, SaveKind::PromoteNewToTable(..)); match kind { SaveKind::PromoteNewToTable(schema, name) => { if let Some(tab_view) = self.workspace_tab_view.clone() { @@ -335,43 +247,6 @@ impl App { } SaveKind::Skip => {} } - - // A close-with-pending dialog that picked "Save" put this tab - // in close_after_save and waits for the save to drain it. A - // promoted draft is already closed, so only a tab that is still - // open is closed here; the window-close check runs either way - // because the counter entry was consumed. - let drained = super::dec_close_after_save(&mut self.close_after_save.borrow_mut(), &tab_id); - if drained && !kind_promoted { - sender.input(AppMsg::WorkspaceTabClosed(tab_id)); - } - if self.close_window_after_save.get() && self.close_after_save.borrow().is_empty() { - self.close_window_after_save.set(false); - self.window.close(); - } - } - - pub(super) fn on_structure_save_failed(&mut self, tab_id: Uuid, message: String) { - if self.in_flight_saves.get() > 0 { - self.in_flight_saves.set(self.in_flight_saves.get() - 1); - } - self.refuse_structure_save(tab_id, message); - } - - /// End a save with `message`. A save refused before it ran never - /// counted itself in `in_flight_saves`, so only a failed run gives - /// one back, in `on_structure_save_failed`. - fn refuse_structure_save(&mut self, tab_id: Uuid, message: String) { - self.structure_saves_in_flight.borrow_mut().remove(&tab_id); - // Match the Browse-side `SaveFailedForTab` handler: a save - // that started from the close-with-pending dialog left this - // tab in `close_after_save`, and the window-close-after-save - // intent in `close_window_after_save`. Both must be cleared - // on failure or the next unrelated SaveCompleted on another - // tab will spuriously close the window. - self.close_after_save.borrow_mut().remove(&tab_id); - self.close_window_after_save.set(false); - self.send_to_structure_tab(tab_id, StructureTabInput::SaveFailed(message)); } /// Sidebar "Show CREATE TABLE": the statements that recreate the @@ -407,7 +282,7 @@ impl App { }); } - fn send_to_structure_tab(&self, tab_id: Uuid, msg: StructureTabInput) { + pub(super) fn send_to_structure_tab(&self, tab_id: Uuid, msg: StructureTabInput) { let structure = self .workspace_tabs .borrow() @@ -498,37 +373,6 @@ impl App { } } -/// Plan a schema change with the dialect of the connection that runs -/// it, and run the plan as one write. The statements come back whether -/// or not the write went through, for the history. `operation` is what -/// a connection with no DDL dialect refuses. -async fn run_schema_plan

( - session: Arc, - table: TableRef, - operation: DdlOperation, - plan: P, -) -> Result<(Vec, Result), OperationError> -where - P: Fn(&dyn DdlDialect, &TableRef) -> Result + Send + Sync, -{ - session - .run(OperationKind::Save(WriteKind::SchemaChanges), move |lease, options| { - let table = table.clone(); - let dialect = lease.dialect(); - let planned = match dialect.ddl() { - Some(ddl) => plan(ddl, &table).map_err(SqlBuildError::from), - None => Err(SqlBuildError::from(BuildDdlError::Unsupported(operation))), - }; - async move { - let plan = planned?; - let statements: Vec = plan.sql().into_iter().map(str::to_owned).collect(); - let written = lease.write_batch(&plan.into_write_batch(table), options).await; - Ok((statements, written.map_err(OperationError::from))) - } - }) - .await -} - /// The table's definition and the dialect of the connection that read /// it, so the DDL built from it is spelled for that same server. async fn read_definition( diff --git a/linux/crates/app/src/ui/app/teardown_guard.rs b/linux/crates/app/src/ui/app/teardown_guard.rs deleted file mode 100644 index a83f17b8c3..0000000000 --- a/linux/crates/app/src/ui/app/teardown_guard.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::cell::Cell; - -use relm4::adw::prelude::*; -use relm4::{ComponentSender, adw}; - -use super::{App, AppMsg, WorkspaceTab}; - -/// What closing the workspace would throw away. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub(super) struct WorkspaceLoss { - pub(super) unsaved_edits: bool, - pub(super) open_transactions: bool, -} - -impl WorkspaceLoss { - pub(super) fn is_empty(self) -> bool { - !self.unsaved_edits && !self.open_transactions - } - - fn body(self) -> String { - match (self.unsaved_edits, self.open_transactions) { - (true, true) => crate::i18n::gettext( - "Every tab closes. Unsaved row edits and structure changes are lost, and the server rolls back each open editor transaction.", - ), - (false, true) => { - crate::i18n::gettext("Every tab closes, and the server rolls back each open editor transaction.") - } - _ => crate::i18n::gettext("Every tab closes, and unsaved row edits and structure changes are lost."), - } - } -} - -/// Why the workspace is closing, which names the button that confirms it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum Teardown { - Disconnect, - Switch, -} - -impl App { - pub(super) fn workspace_loss(&self) -> WorkspaceLoss { - let unsaved_edits = !self.dirty_tabs().is_empty(); - let open_transactions = self - .workspace_tabs - .borrow() - .values() - .any(|tab| matches!(tab, WorkspaceTab::Editor(slot) if slot.editor.has_open_transaction())); - WorkspaceLoss { - unsaved_edits, - open_transactions, - } - } - - /// Send `proceed` once the user agrees to lose what the workspace - /// holds, or at once when it holds nothing. - pub(super) fn confirm_teardown(&self, teardown: Teardown, proceed: AppMsg, sender: ComponentSender) { - let loss = self.workspace_loss(); - if loss.is_empty() { - sender.input(proceed); - return; - } - let (heading, confirm) = match teardown { - Teardown::Disconnect => ( - crate::i18n::gettext("Disconnect and Discard Changes?"), - crate::i18n::gettext("Discard and Disconnect"), - ), - Teardown::Switch => ( - crate::i18n::gettext("Switch Connection and Discard Changes?"), - crate::i18n::gettext("Discard and Switch"), - ), - }; - let dialog = adw::AlertDialog::new(Some(&heading), Some(&loss.body())); - dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); - dialog.add_response("discard", &confirm); - dialog.set_response_appearance("discard", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - let proceed = Cell::new(Some(proceed)); - dialog.connect_response(None, move |_, response| { - if response == "discard" - && let Some(message) = proceed.take() - { - sender.input(message); - } - }); - dialog.present(Some(&self.window)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn each_kind_of_loss_has_its_own_explanation() { - let edits = WorkspaceLoss { - unsaved_edits: true, - open_transactions: false, - }; - let transactions = WorkspaceLoss { - unsaved_edits: false, - open_transactions: true, - }; - let both = WorkspaceLoss { - unsaved_edits: true, - open_transactions: true, - }; - - assert!(WorkspaceLoss::default().is_empty()); - assert!(!edits.is_empty() && !transactions.is_empty()); - assert!(edits.body().contains("edits") && !edits.body().contains("transaction")); - assert!(transactions.body().contains("transaction") && !transactions.body().contains("edits")); - assert!(both.body().contains("edits") && both.body().contains("transaction")); - } -} diff --git a/linux/crates/app/src/ui/app/window_tabs.rs b/linux/crates/app/src/ui/app/window_tabs.rs new file mode 100644 index 0000000000..063bf72fa1 --- /dev/null +++ b/linux/crates/app/src/ui/app/window_tabs.rs @@ -0,0 +1,59 @@ +use std::cell::RefCell; + +use relm4::adw; +use relm4::adw::prelude::*; +use relm4::gtk::glib; + +use super::workspace_persistence::WorkspaceTabs; +use super::{WorkspaceTab, read_workspace_tab_id}; +use crate::workspace::{TabDirectory, TabEntry}; + +/// The window's tabs as the close prompts read them: in tab bar order, +/// by the names their tabs show. +pub(super) struct WindowTabs { + tabs: WorkspaceTabs, + view: RefCell>, +} + +impl WindowTabs { + pub(super) fn new(tabs: WorkspaceTabs) -> Self { + Self { + tabs, + view: RefCell::new(glib::WeakRef::new()), + } + } + + /// The tab view built for a connection, or `None` once it is torn + /// down. + pub(super) fn set_view(&self, view: Option<&adw::TabView>) { + let weak = glib::WeakRef::new(); + weak.set(view); + self.view.replace(weak); + } +} + +impl TabDirectory for WindowTabs { + fn tabs(&self) -> Vec { + let Some(view) = self.view.borrow().upgrade() else { + return Vec::new(); + }; + let tabs = self.tabs.borrow(); + let pages = view.pages(); + (0..pages.n_items()) + .filter_map(|index| pages.item(index).and_downcast::()) + .filter_map(|page| { + let id = read_workspace_tab_id(&page)?; + let tab = tabs.get(&id)?; + let title = page.title(); + Some(TabEntry { + id, + title: title.strip_prefix("• ").unwrap_or(&title).to_owned(), + open_transaction: matches!( + tab, + WorkspaceTab::Editor(slot) if slot.editor.has_open_transaction() + ), + }) + }) + .collect() + } +} diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index 3a0c958d96..3df6610aa0 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -12,7 +12,7 @@ use tablepro_storage::{DraftId, DraftScope}; use uuid::Uuid; use crate::services::from_session::FromSession; -use crate::ui::browse_tab::{BrowseTab, BrowseTabInit, BrowseTabInput, ExportRequest, SaveRequest}; +use crate::ui::browse_tab::{BrowseTab, BrowseTabInit, BrowseTabInput, ExportRequest}; use crate::ui::editor::{SqlEditor, SqlEditorInit, derive_tab_label}; use crate::ui::structure_tab::{StructureMode, StructureTab, StructureTabInit}; use crate::workspace::{ @@ -66,6 +66,7 @@ impl App { self.toast_overlay.downgrade(), ); let chrome = build_workspace_chrome(sender.input_sender(), create_tab); + self.window_tabs.set_view(Some(&chrome.view)); self.workspace_root = Some(chrome.overview); self.workspace_tab_view = Some(chrome.view); } @@ -447,149 +448,6 @@ impl App { .unwrap_or_default() } - pub(super) fn close_workspace_tab_by_id(&mut self, id: Uuid, sender: ComponentSender) { - let Some(tab_view) = self.workspace_tab_view.clone() else { - return; - }; - - let pending = self.dirty_tabs().contains(&id); - let open_transaction = matches!( - self.workspace_tabs.borrow().get(&id), - Some(WorkspaceTab::Editor(slot)) if slot.editor.has_open_transaction() - ); - if open_transaction { - self.confirm_close_with_open_transaction(id, &tab_view, sender); - return; - } - if pending { - let page = self.workspace_tabs.borrow().get(&id).map(|t| match t { - WorkspaceTab::Editor(s) => s.page.clone(), - WorkspaceTab::Structure(s) => s.page.clone(), - WorkspaceTab::Table(s) => s.page.clone(), - }); - let Some(page) = page else { return }; - let raw_title = page.title().to_string(); - let tab_label = raw_title.strip_prefix("• ").unwrap_or(&raw_title).to_string(); - // Cancel | Discard(destructive) | Save(suggested), Save is - // default. Mirrors GNOME Text Editor's close-with-unsaved - // template (libadwaita AdwAlertDialog reference). Naming - // the tab in the heading + factual body copy follows the - // GNOME HIG pattern for destructive-confirmation dialogs. - let dialog = adw::AlertDialog::new(None, None); - dialog.set_heading(Some(&crate::i18n::gettext_f( - "Save changes to “{name}”?", - &[("name", &tab_label)], - ))); - dialog.set_body(&crate::i18n::gettext( - "Unsaved changes will be permanently lost if you discard them.", - )); - dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); - dialog.add_response("discard", &crate::i18n::gettext("Discard")); - dialog.add_response("save", &crate::i18n::gettext("Save")); - dialog.set_response_appearance("discard", adw::ResponseAppearance::Destructive); - dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("save")); - dialog.set_close_response("cancel"); - let tab_view_for_resp = tab_view.clone(); - let page_for_resp = page.clone(); - let sender_for_resp = sender.clone(); - let close_after_save = self.close_after_save.clone(); - // Snapshot which trackers actually have pending changes - // before the dialog opens. Drives the discard + save - // branches so a Table tab whose only dirt is on the - // Structure side doesn't try to save through the Browse - // path (and vice versa). - let data_tracker = self - .change_trackers - .get(id) - .filter(|tracker| tracker.pending_count() > 0); - let structure_tracker = self.structure_trackers.get(id).filter(|tracker| tracker.has_pending()); - let (data_dirty, struct_dirty) = (data_tracker.is_some(), structure_tracker.is_some()); - dialog.connect_response(None, move |dlg, response| { - dlg.close(); - match response { - "discard" => { - // The tab closes next, so no grid is left to - // show the discarded rows. - if let Some(tracker) = &data_tracker { - let _ = tracker.discard(); - } - if let Some(tracker) = &structure_tracker { - tracker.clear(); - } - sender_for_resp.input(AppMsg::WorkspaceTabClosed(id)); - } - "save" => { - // Mark the tab so SaveCompletedForTab will close - // it once the transaction commits. The counter - // increments once per dispatched save: a Table - // tab dirty on both sides bumps to 2, and the - // close fires only after BOTH saves drain it. - // SaveFailed removes the entry entirely and - // aborts the close. - let pending_saves: u32 = u32::from(data_dirty) + u32::from(struct_dirty); - if pending_saves > 0 { - *close_after_save.borrow_mut().entry(id).or_insert(0) += pending_saves; - } - // Revert AdwTabView's "closing" state for now - // (the user might still cancel via SaveFailed). - tab_view_for_resp.close_page_finish(&page_for_resp, false); - // Dispatch the right save path per dirty side. - if data_dirty { - sender_for_resp.input(AppMsg::SaveActiveBrowseTabById(id)); - } - if struct_dirty { - sender_for_resp.input(AppMsg::SaveActiveStructureTabById(id)); - } - } - _ => { - // Cancel: revert AdwTabView's "closing" state - // so the page stays open. Without this the X - // click would still dismiss the page. - tab_view_for_resp.close_page_finish(&page_for_resp, false); - } - } - }); - dialog.present(Some(&self.window)); - return; - } - - self.finish_close_workspace_tab(id, &tab_view); - } - - /// An editor tab's connection ends with the tab, and the server - /// rolls back whatever transaction it held, so closing one with a - /// transaction open asks first. - fn confirm_close_with_open_transaction(&self, id: Uuid, tab_view: &adw::TabView, sender: ComponentSender) { - let Some(page) = self.workspace_tabs.borrow().get(&id).map(|tab| match tab { - WorkspaceTab::Editor(s) => s.page.clone(), - WorkspaceTab::Structure(s) => s.page.clone(), - WorkspaceTab::Table(s) => s.page.clone(), - }) else { - return; - }; - let dialog = adw::AlertDialog::new( - Some(&crate::i18n::gettext("Close Tab With an Open Transaction?")), - Some(&crate::i18n::gettext( - "Closing the tab ends its connection, and the server rolls back the changes the transaction has not committed.", - )), - ); - dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); - dialog.add_response("close", &crate::i18n::gettext("Close Tab")); - dialog.set_response_appearance("close", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - let tab_view = tab_view.clone(); - dialog.connect_response(None, move |_, response| { - if response == "close" { - sender.input(AppMsg::WorkspaceTabClosed(id)); - } else { - tab_view.close_page_finish(&page, false); - } - }); - dialog.present(Some(&self.window)); - } - /// Tear down a tab without prompting. Internal helper called once /// the close-with-pending dialog (if any) has resolved. pub(super) fn finish_close_workspace_tab(&mut self, id: Uuid, tab_view: &adw::TabView) { @@ -602,13 +460,6 @@ impl App { if let Some(descriptor) = describe_closed_tab(&removed) { push_closed_tab(&self.closed_tabs_stack, descriptor); } - // Drop any close-after-save / window-close-after-save intent - // pinned to this tab. `close_tabs_for_table` (Drop Table) - // calls into here directly without going through the per-tab - // close prompt, so a stale entry would otherwise live on - // forever and cause a future unrelated SaveCompleted to - // spuriously close the window. - self.close_after_save.borrow_mut().remove(&id); match &removed { WorkspaceTab::Editor(slot) => { slot.editor.shutdown(); @@ -619,12 +470,10 @@ impl App { WorkspaceTab::Structure(slot) => { slot.structure.shutdown(); self.structure_trackers.unregister(slot.id); - self.structure_saves_in_flight.borrow_mut().remove(&slot.id); } WorkspaceTab::Table(slot) => { slot.browse.shutdown(); self.change_trackers.unregister(slot.id); - self.structure_saves_in_flight.borrow_mut().remove(&slot.id); } } let page = match &removed { @@ -639,6 +488,7 @@ impl App { self.workspace_outer_stack.set_visible_child_name("empty"); } self.refresh_window_title(); + self.refresh_inhibitor(); } pub(super) fn close_active_workspace_tab(&mut self, sender: ComponentSender) { @@ -934,8 +784,10 @@ impl App { self.workspace_root_added.set(false); } self.workspace_outer_stack.set_visible_child_name("empty"); + self.window_tabs.set_view(None); self.workspace_tab_view = None; self.workspace_tabs.borrow_mut().clear(); + self.refresh_inhibitor(); } /// Write the tabs and the editor text as they are now, before the @@ -1365,7 +1217,9 @@ fn connect_structure(structure: &StructureTab, tab_id: Uuid, input: &relm4::Send let dirty = input.clone(); structure.connect_dirty_changed(move |_, is_dirty| dirty.emit(AppMsg::StructureTabDirtyChanged(tab_id, is_dirty))); let save = input.clone(); - structure.connect_save_requested(move |_| save.emit(AppMsg::SaveActiveStructureTabById(tab_id))); + structure.connect_save_requested(move |_| save.emit(AppMsg::SaveTab(tab_id))); + let stop = input.clone(); + structure.connect_stop_save_requested(move |_| stop.emit(AppMsg::StopSaving(tab_id))); let drop_table = input.clone(); structure.connect_drop_table_requested(move |_, schema, table| { drop_table.emit(AppMsg::DropTablePrompt { schema, table }); @@ -1397,21 +1251,9 @@ fn connect_browse(browse: &BrowseTab, tab_id: Uuid, input: &relm4::Sender, #[template_child] pub save_button: TemplateChild, + #[template_child] + pub saving_spinner: TemplateChild, + #[template_child] + pub stop_button: TemplateChild, pub(super) model: RefCell>, pub(super) queue: RefCell>, @@ -182,9 +186,8 @@ impl ObjectImpl for BrowseTab { Signal::builder("dirty-changed") .param_types([bool::static_type()]) .build(), - Signal::builder("save-requested") - .param_types([glib::BoxedAnyObject::static_type()]) - .build(), + Signal::builder("save-requested").build(), + Signal::builder("stop-save-requested").build(), ] }) } diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index ea583ca869..95c50b8dc5 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -36,7 +36,8 @@ pub(crate) use self::browse_request_kind::BrowseRequestKind; pub(crate) use self::page_reply::PageReply; use super::grid::{CellEditor, CellPreset, ColumnWidthBinding, GridMsg, RowObject, TabGridContext, build_column_view}; use crate::runtime::{FailedRequests, RequestSequencer}; -use crate::services::change_tracker::{RowKey, StatementSource, TrackedChanges}; +use crate::services::change_tracker::{RowKey, StatementSource}; +use crate::services::save_batch::SaveTarget; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_delta::{TrackerDelta, VisualChange}; use crate::services::tracker_edit::TrackerEdit; @@ -82,16 +83,6 @@ pub struct ExportRequest { pub target: Option, } -/// The tracker's changes to save as one batch. The columns are the ones -/// the change set was built against, so the plan binds the same shape; -/// `changes` maps a failing step back to its grid row. -#[derive(Debug, Clone)] -pub struct SaveRequest { - pub table: TableRef, - pub columns: Vec, - pub changes: TrackedChanges, -} - impl BrowseTab { pub fn new(init: BrowseTabInit) -> Self { let tab: Self = glib::Object::new(); @@ -198,6 +189,8 @@ impl BrowseTab { pending_revealer: imp.pending_revealer.get(), save_button: imp.save_button.get(), discard_button: imp.discard_button.get(), + saving_spinner: imp.saving_spinner.get(), + stop_button: imp.stop_button.get(), pending_label: imp.pending_label.get(), grid_sender, suppress_combo_emit: Cell::new(true), @@ -355,13 +348,17 @@ impl BrowseTab { ) } - pub fn connect_save_requested(&self, f: F) -> glib::SignalHandlerId { + /// Save asked the window to write what the tab's tracker holds. + pub fn connect_save_requested(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure("save-requested", false, glib::closure_local!(move |tab: &Self| f(tab))) + } + + /// Stop Saving asked the window to stop the tab's save. + pub fn connect_stop_save_requested(&self, f: F) -> glib::SignalHandlerId { self.connect_closure( - "save-requested", + "stop-save-requested", false, - glib::closure_local!(move |tab: &Self, payload: glib::BoxedAnyObject| { - f(tab, payload.borrow::().clone()); - }), + glib::closure_local!(move |tab: &Self| f(tab)), ) } @@ -380,9 +377,8 @@ impl BrowseTab { } BrowseTabOutput::ShowToast(text) => self.emit_by_name::<()>("toast", &[&text]), BrowseTabOutput::DirtyChanged(dirty) => self.emit_by_name::<()>("dirty-changed", &[&dirty]), - BrowseTabOutput::ExecuteTransaction(request) => { - self.emit_by_name::<()>("save-requested", &[&glib::BoxedAnyObject::new(request)]); - } + BrowseTabOutput::SaveRequested => self.emit_by_name::<()>("save-requested", &[]), + BrowseTabOutput::StopSaveRequested => self.emit_by_name::<()>("stop-save-requested", &[]), } } @@ -435,6 +431,7 @@ impl BrowseTab { on_click(&imp.export_button, || BrowseTabInput::ExportCurrentPage); on_click(&imp.discard_button, || BrowseTabInput::DiscardAll); on_click(&imp.save_button, || BrowseTabInput::CommitSave); + on_click(&imp.stop_button, || BrowseTabInput::StopSaving); let tab = self.downgrade(); imp.page_size_dropdown.connect_selected_notify(move |dropdown| { let index = usize::try_from(dropdown.selected()).unwrap_or(usize::MAX); @@ -611,6 +608,8 @@ pub(crate) struct BrowseModel { pending_revealer: gtk::Revealer, save_button: gtk::Button, discard_button: gtk::Button, + saving_spinner: adw::Spinner, + stop_button: gtk::Button, pending_label: gtk::Label, grid_sender: relm4::Sender, /// Set until the first page lands, so the page-size dropdown being @@ -681,6 +680,9 @@ pub(crate) enum BrowseTabInput { Undo, Redo, CommitSave, + /// The window started saving the tab's changes. + SaveStarted, + StopSaving, DiscardAll, DiscardAndRefresh, /// A delete the user confirmed, or one small enough to need no @@ -689,10 +691,14 @@ pub(crate) enum BrowseTabInput { rows: Vec<(RowKey, Vec)>, drafts: Vec, }, - SaveCompleted, + /// The save landed. Edits made while it ran stay pending. + SaveCommitted, /// The pending changes stay, so the user can fix the cause and /// save again. SaveFailed(String), + /// The save may have landed. The changes stay, and the tracker + /// refuses to save them again until the table is read again. + SaveOutcomeUnknown(String), FlashErrorRow(StatementSource), CopySelectedRowsAsTsv, PasteNotSupported, @@ -717,7 +723,8 @@ pub(crate) enum BrowseTabOutput { }, ShowToast(String), DirtyChanged(bool), - ExecuteTransaction(SaveRequest), + SaveRequested, + StopSaveRequested, } impl BrowseModel { @@ -807,6 +814,15 @@ impl BrowseModel { qualified(self.schema.as_deref(), &self.table) } + /// While a save runs, Save and Discard are off, so a double click + /// cannot send two, and Stop Saving takes their place. + fn show_saving(&self, saving: bool) { + self.save_button.set_sensitive(!saving); + self.discard_button.set_sensitive(!saving); + self.saving_spinner.set_visible(saving); + self.stop_button.set_visible(saving); + } + fn refresh_pending_bar(&self, count: usize) { let visible = count > 0; if visible { @@ -1385,6 +1401,10 @@ impl BrowseModel { self.shown_view = None; } self.current_columns = columns.clone(); + self.tracker.set_target(SaveTarget { + table: self.table_ref(), + columns: columns.clone(), + }); if let Some(result) = self.current_result.as_mut() && result.columns.is_empty() { @@ -1668,30 +1688,9 @@ impl BrowseModel { } BrowseTabInput::GoToFirstRow => self.scroll_to_row(|_| Some(0)), BrowseTabInput::GoToLastRow => self.scroll_to_row(|n| n.checked_sub(1)), - BrowseTabInput::CommitSave => { - let columns = self.current_columns.clone(); - match self.tracker.change_set(&columns) { - Ok(changes) if !changes.changes.is_empty() => { - // Both buttons stay off while the save runs, so a - // double click cannot send two. - self.save_button.set_sensitive(false); - self.discard_button.set_sensitive(false); - self.capture_focus_for_restore(); - fx.output(BrowseTabOutput::ExecuteTransaction(SaveRequest { - table: self.table_ref(), - columns, - changes, - })); - } - Ok(_) => self.refresh_pending_bar(0), - Err(e) => { - fx.output(BrowseTabOutput::ShowSelectionAlert { - title: crate::i18n::gettext("Cannot save"), - body: format!("{e}"), - }); - } - } - } + BrowseTabInput::CommitSave => fx.output(BrowseTabOutput::SaveRequested), + BrowseTabInput::SaveStarted => self.show_saving(true), + BrowseTabInput::StopSaving => fx.output(BrowseTabOutput::StopSaveRequested), BrowseTabInput::DiscardAll => { let delta = self.tracker.discard(); self.apply_tracker_delta(delta, fx); @@ -1715,25 +1714,30 @@ impl BrowseModel { selection.unselect_all(); } } - BrowseTabInput::SaveCompleted => { - // The reload replaces every row; dropping the saved - // drafts from the store first would blank them until it - // lands. - let delta = self.tracker.discard(); - self.sync_pending_chrome(delta.pending_count, fx); - self.save_button.set_sensitive(true); - self.discard_button.set_sensitive(true); + BrowseTabInput::SaveCommitted => { + self.show_saving(false); + // The tracker already let go of what the save wrote. The + // reload replaces every row; dropping the saved drafts + // from the store first would blank them until it lands. + self.sync_pending_chrome(self.tracker.pending_count(), fx); + self.capture_focus_for_restore(); self.fetch(BrowseRequestKind::Page, fx); self.fetch(BrowseRequestKind::Count, fx); } BrowseTabInput::SaveFailed(message) => { - self.save_button.set_sensitive(true); - self.discard_button.set_sensitive(true); + self.show_saving(false); fx.output(BrowseTabOutput::ShowSelectionAlert { title: crate::i18n::gettext("Save failed"), body: message, }); } + BrowseTabInput::SaveOutcomeUnknown(message) => { + self.show_saving(false); + fx.output(BrowseTabOutput::ShowSelectionAlert { + title: crate::i18n::gettext("Save Result Unknown"), + body: message, + }); + } BrowseTabInput::FlashErrorRow(source) => self.flash_error_row(&source), BrowseTabInput::FocusInsertedDraft => self.focus_inserted_draft(), BrowseTabInput::Undo => { diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 6024817be9..759ed6ec1f 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -745,24 +745,52 @@ fn export_and_save_reach_the_window_as_signals() { let harness = Harness::open(script, []); wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); let exported = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); - let saved = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let saved = std::rc::Rc::new(std::cell::Cell::new(0)); let export_sink = exported.clone(); harness.tab().connect_export_results(move |_, request| { export_sink.borrow_mut().push((request.name, request.result.rows.len())); }); let save_sink = saved.clone(); - harness.tab().connect_save_requested(move |_, request| { - save_sink - .borrow_mut() - .push((request.table, request.changes.changes.changes.len())); - }); + harness + .tab() + .connect_save_requested(move |_| save_sink.set(save_sink.get() + 1)); harness.tab().send(BrowseTabInput::ExportCurrentPage); harness.tab().send(BrowseTabInput::InsertRow); harness.tab().send(BrowseTabInput::CommitSave); assert_eq!(*exported.borrow(), [("users".to_owned(), 2)]); - assert_eq!(*saved.borrow(), [(tablepro_core::TableRef::unqualified("users"), 1)]); + assert_eq!(saved.get(), 1); +} + +#[gtk4::test] +fn stop_saving_button_visible_only_while_saving() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let stops = std::rc::Rc::new(std::cell::Cell::new(0)); + let stop_sink = stops.clone(); + harness + .tab() + .connect_stop_save_requested(move |_| stop_sink.set(stop_sink.get() + 1)); + let imp = harness.tab().imp(); + let (stop, spinner, save) = (imp.stop_button.get(), imp.saving_spinner.get(), imp.save_button.get()); + harness.tab().send(BrowseTabInput::InsertRow); + + assert!(!stop.is_visible() && !spinner.is_visible()); + harness.tab().send(BrowseTabInput::SaveStarted); + assert!(stop.is_visible() && spinner.is_visible()); + assert!(!save.is_sensitive(), "Save stayed on while a save ran"); + + stop.emit_clicked(); + assert_eq!(stops.get(), 1); + + harness.tab().send(BrowseTabInput::SaveFailed("rolled back".to_owned())); + assert!(!stop.is_visible() && !spinner.is_visible()); + assert!(save.is_sensitive()); } #[gtk4::test] diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 93d077658f..82e10e8b6c 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -21,7 +21,7 @@ mod saved_queries_dialog; mod sidebar_row; mod ssh_inputs; mod ssh_section; -mod structure_tab; +pub(crate) mod structure_tab; mod structure_tab_dialogs; mod style_scheme_grid; mod style_scheme_resolver; diff --git a/linux/crates/app/src/ui/structure_tab/imp.rs b/linux/crates/app/src/ui/structure_tab/imp.rs index 7abaa80e33..7a5ec4777e 100644 --- a/linux/crates/app/src/ui/structure_tab/imp.rs +++ b/linux/crates/app/src/ui/structure_tab/imp.rs @@ -45,6 +45,10 @@ pub struct StructureTab { pub discard_button: TemplateChild, #[template_child] pub save_button: TemplateChild, + #[template_child] + pub saving_spinner: TemplateChild, + #[template_child] + pub stop_button: TemplateChild, pub(super) model: RefCell>, pub(super) queue: RefCell>, @@ -79,6 +83,7 @@ impl ObjectImpl for StructureTab { .param_types([bool::static_type()]) .build(), Signal::builder("save-requested").build(), + Signal::builder("stop-save-requested").build(), Signal::builder("drop-table-requested") .param_types([String::static_type(), String::static_type()]) .build(), diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index 05d7e033de..c145290fea 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -189,6 +189,9 @@ impl StructureTab { save_button: imp.save_button.get(), discard_button: imp.discard_button.get(), drop_button: imp.drop_button.get(), + saving_spinner: imp.saving_spinner.get(), + stop_button: imp.stop_button.get(), + saving: false, last_dirty: false, suppress_emit: imp.suppress_emit.clone(), next_column_seq: 1, @@ -277,6 +280,15 @@ impl StructureTab { self.connect_closure("save-requested", false, glib::closure_local!(move |tab: &Self| f(tab))) } + /// Stop Saving asked the window to stop the tab's save. + pub fn connect_stop_save_requested(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "stop-save-requested", + false, + glib::closure_local!(move |tab: &Self| f(tab)), + ) + } + pub fn connect_drop_table_requested, String) + 'static>( &self, f: F, @@ -308,6 +320,7 @@ impl StructureTab { match output { StructureTabOutput::DirtyChanged(dirty) => self.emit_by_name::<()>("dirty-changed", &[&dirty]), StructureTabOutput::SaveRequested => self.emit_by_name::<()>("save-requested", &[]), + StructureTabOutput::StopSaveRequested => self.emit_by_name::<()>("stop-save-requested", &[]), StructureTabOutput::DropTableRequested { schema, table } => { self.emit_by_name::<()>("drop-table-requested", &[&schema, &table]); } @@ -325,6 +338,7 @@ impl StructureTab { on_click(&imp.save_button, || StructureTabInput::Save); on_click(&imp.discard_button, || StructureTabInput::Discard); on_click(&imp.drop_button, || StructureTabInput::DropTableRequested); + on_click(&imp.stop_button, || StructureTabInput::StopSaving); // Without a retry a transient network blip on a remote database // would force the user to close the tab and open it again. on_click(&imp.retry_button, || StructureTabInput::Retry); @@ -397,6 +411,10 @@ pub(crate) struct StructureModel { save_button: gtk::Button, discard_button: gtk::Button, drop_button: gtk::Button, + saving_spinner: adw::Spinner, + stop_button: gtk::Button, + /// A save of the tab is running, so Save and Discard stay off. + saving: bool, last_dirty: bool, /// Suppress reentrant rebuilds: programmatic `set_text` / @@ -441,12 +459,21 @@ pub(crate) enum StructureTabInput { /// The error page's Try Again. Retry, Save, + /// The window started saving the tab's pending structure. + SaveStarted, + StopSaving, Discard, + /// Read the table again and drop the draft, for a save whose outcome + /// nobody knows. + Reload, DropTableRequested, SaveCompleted { new_table_name: Option, }, SaveFailed(String), + /// The save may have landed; the tracker refuses to save again until + /// the table is read again or the draft is discarded. + SaveOutcomeUnknown(String), /// Rebuild the lists and the preview from the model. Refresh, ColumnEdited { @@ -479,6 +506,7 @@ pub(crate) enum StructureTabOutput { DirtyChanged(bool), /// Save asked the window to write the tracker's pending structure. SaveRequested, + StopSaveRequested, DropTableRequested { schema: Option, table: String, @@ -547,7 +575,7 @@ impl StructureModel { } fn refresh_buttons(&self, pending_count: usize) { - let has_pending = pending_count > 0; + let has_pending = pending_count > 0 && !self.saving; self.save_button .set_sensitive(has_pending && self.invalid_defaults.is_empty()); self.discard_button.set_sensitive(has_pending); @@ -967,7 +995,22 @@ impl StructureModel { fx.input(StructureTabInput::Refresh); } StructureTabInput::Save => self.save(fx), + StructureTabInput::SaveStarted => self.show_saving(true), + StructureTabInput::StopSaving => fx.output(StructureTabOutput::StopSaveRequested), + StructureTabInput::Reload => { + // A table the save may have created has nothing to read + // yet, so a new one is only cleared. + match self.mode { + StructureMode::Edit => { + self.show_loading(); + self.fetch_definition(); + } + StructureMode::New => fx.input(StructureTabInput::Discard), + } + } StructureTabInput::Discard => { + // This also lets go of a save whose outcome was unknown. + self.tracker.clear(); // New mode clears everything, since a new table starts // empty. let restored = match (self.mode, &self.definition) { @@ -995,12 +1038,21 @@ impl StructureModel { } StructureTabInput::SaveCompleted { new_table_name } => self.save_completed(new_table_name, fx), StructureTabInput::SaveFailed(message) => { + self.show_saving(false); self.recompute_dirty_state(fx); fx.output(StructureTabOutput::ShowAlert { title: crate::i18n::gettext("Save failed"), body: message, }); } + StructureTabInput::SaveOutcomeUnknown(message) => { + self.show_saving(false); + self.recompute_dirty_state(fx); + fx.output(StructureTabOutput::ShowAlert { + title: crate::i18n::gettext("Save Result Unknown"), + body: message, + }); + } } } @@ -1096,8 +1148,6 @@ impl StructureModel { match pending.plan(ddl) { Ok(_) => { self.tracker.set_pending(Some(pending)); - self.save_button.set_sensitive(false); - self.discard_button.set_sensitive(false); fx.output(StructureTabOutput::SaveRequested); } Err(error) => fx.output(StructureTabOutput::ShowAlert { @@ -1107,7 +1157,20 @@ impl StructureModel { } } + /// While a save runs, Save and Discard are off, so a double click + /// cannot send two, and Stop Saving takes their place. + fn show_saving(&mut self, saving: bool) { + self.saving = saving; + self.saving_spinner.set_visible(saving); + self.stop_button.set_visible(saving); + if saving { + self.save_button.set_sensitive(false); + self.discard_button.set_sensitive(false); + } + } + fn save_completed(&mut self, new_table_name: Option, fx: &mut StructureEffects) { + self.show_saving(false); if let Some(name) = new_table_name { self.mode = StructureMode::Edit; self.draft.borrow_mut().table.name = name.clone(); diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index 6625b342c1..094ac78c5d 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -502,8 +502,11 @@ fn an_edit_previews_the_plan_and_saves_through_the_tracker() { assert_eq!(preview, "ALTER TABLE \"users\" ALTER COLUMN \"id\" SET DEFAULT 42;"); assert_eq!(saves.get(), 1); - let pending = tracker.begin_save().expect("the change is pending"); + let pending = tracker.begin_save().expect("a save").expect("the change is pending"); assert_eq!(pending.changes.altered_columns.len(), 1); + tracker.settle_save(&crate::workspace::SaveOutcome::RolledBack( + crate::workspace::RollbackCause::NotApplied, + )); tab.send(StructureTabInput::ColumnEdited { index: 0, @@ -514,14 +517,48 @@ fn an_edit_previews_the_plan_and_saves_through_the_tracker() { "a default that does not parse can be saved" ); // What a close prompt's Save reads. - let typed = tracker.begin_save().expect("the change is still pending"); - assert_eq!( - typed.check(), - Err(crate::services::pending_structure::SaveBlocker::UnparsedDefault( - "id".to_owned() - )) - ); + assert!(matches!( + tracker.begin_save(), + Err(crate::workspace::SaveRejection::Invalid(_)) + )); drop(tab); drain_main_context(); runtime.shutdown(); } + +#[gtk4::test] +fn stop_saving_shows_only_while_a_save_runs() { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(definition("id"))); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let stops = std::rc::Rc::new(std::cell::Cell::new(0)); + let stop_sink = stops.clone(); + harness + .tab() + .connect_stop_save_requested(move |_| stop_sink.set(stop_sink.get() + 1)); + harness.tab().send(StructureTabInput::ColumnEdited { + index: 0, + field: ColumnField::Comment(Some("key".to_owned())), + }); + let imp = harness.tab().imp(); + let (stop, save) = (imp.stop_button.get(), imp.save_button.get()); + assert!(!stop.is_visible()); + assert!(save.is_sensitive()); + + harness.tab().send(StructureTabInput::SaveStarted); + assert!(stop.is_visible() && imp.saving_spinner.is_visible()); + harness.tab().send(StructureTabInput::ColumnEdited { + index: 0, + field: ColumnField::Comment(Some("the key".to_owned())), + }); + assert!(!save.is_sensitive(), "an edit during the save turned Save back on"); + stop.emit_clicked(); + assert_eq!(stops.get(), 1); + + harness + .tab() + .send(StructureTabInput::SaveFailed("rolled back".to_owned())); + assert!(!stop.is_visible() && !imp.saving_spinner.is_visible()); + assert!(save.is_sensitive()); +} diff --git a/linux/crates/app/src/workspace/close_decision.rs b/linux/crates/app/src/workspace/close_decision.rs new file mode 100644 index 0000000000..83899ed8cf --- /dev/null +++ b/linux/crates/app/src/workspace/close_decision.rs @@ -0,0 +1,7 @@ +/// How a close ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloseDecision { + /// Nothing is left to lose, or the user agreed to lose it. + Approved, + Cancelled, +} diff --git a/linux/crates/app/src/workspace/close_guard.rs b/linux/crates/app/src/workspace/close_guard.rs new file mode 100644 index 0000000000..aa26edadba --- /dev/null +++ b/linux/crates/app/src/workspace/close_guard.rs @@ -0,0 +1,352 @@ +use std::collections::HashSet; + +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; +use uuid::Uuid; + +use super::open_transaction_dialog::confirm_open_transactions; +use super::save_changes_dialog::{SaveChoice, ask_save_changes}; +use super::unknown_outcome_dialog::{UnknownChoice, resolve_unknown_outcome}; +use super::waiting_dialog::wait_for_saves; +use super::{CloseDecision, CloseScope, CloseStep, DirtyTab, SaveRejection, SaveStart, WorkspaceHandle}; + +/// Decides whether a tab, or every tab of a window, may close. +/// +/// It reads the workspace again after every answer and every settled +/// save rather than following a plan made up front, so a save that +/// rolls back is asked about again and one that lands is not. +pub(crate) struct CloseGuard; + +impl CloseGuard { + pub(crate) async fn run(parent: >k::Widget, handle: &WorkspaceHandle, scope: CloseScope) -> CloseDecision { + let mut acknowledged = HashSet::new(); + loop { + match handle.close_step(scope, &acknowledged) { + CloseStep::Approve => return CloseDecision::Approved, + CloseStep::WaitForSaves => { + if !wait_for_saves(parent, handle, scope).await { + return CloseDecision::Cancelled; + } + } + CloseStep::ResolveUnknownOutcome(tabs) => match resolve_unknown_outcome(parent, &tabs).await { + UnknownChoice::KeepOpen => return CloseDecision::Cancelled, + UnknownChoice::Reload => { + handle.discard(&ids(&tabs), true); + return CloseDecision::Cancelled; + } + UnknownChoice::Discard => handle.discard(&ids(&tabs), false), + }, + CloseStep::ConfirmOpenTransactions(tabs) => { + if !confirm_open_transactions(parent, &tabs).await { + return CloseDecision::Cancelled; + } + acknowledged.extend(ids(&tabs)); + } + CloseStep::AskSaveChanges(tabs) => match ask_save_changes(parent, &tabs).await { + SaveChoice::Cancel => return CloseDecision::Cancelled, + SaveChoice::Proceed { save, discard } => { + handle.discard(&discard, false); + let refused: Vec<(Uuid, SaveRejection)> = save + .iter() + .zip(handle.save(&save)) + .filter_map(|(tab, start)| match start { + SaveStart::Rejected(rejection) => Some((*tab, rejection)), + SaveStart::Started(_) | SaveStart::NothingToSave => None, + }) + .collect(); + if !refused.is_empty() { + show_refused(parent, &tabs, &refused); + return CloseDecision::Cancelled; + } + } + }, + } + } + } +} + +fn ids(tabs: &[DirtyTab]) -> Vec { + tabs.iter().map(|tab| tab.id).collect() +} + +/// A save that could not start keeps its tab open, with the reason. +fn show_refused(parent: >k::Widget, tabs: &[DirtyTab], refused: &[(Uuid, SaveRejection)]) { + let lines: Vec = refused + .iter() + .map(|(id, rejection)| { + let name = tabs + .iter() + .find(|tab| tab.id == *id) + .map(|tab| tab.title.clone()) + .unwrap_or_default(); + crate::i18n::gettext_f("“{tab}”: {reason}", &[("tab", &name), ("reason", &rejection.message())]) + }) + .collect(); + let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Could Not Save")), Some(&lines.join("\n"))); + dialog.add_response("close", &crate::i18n::gettext("_Close")); + dialog.set_close_response("close"); + dialog.set_default_response(Some("close")); + dialog.present(Some(parent)); +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + use std::time::Duration; + + use tablepro_core::{DriverError, LossPhase, WriteReport}; + use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; + + use super::*; + use crate::test_support::wait_until; + use crate::workspace::dialog_response::{answer, visible_alert}; + use crate::workspace::test_workspace::{WAIT, Workspace}; + use crate::workspace::{PendingState, WorkspaceEvent}; + + fn window() -> adw::Window { + adw::init().expect("libadwaita initialises under the test backend"); + if let Some(settings) = gtk::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let window = adw::Window::new(); + window.present(); + window + } + + fn guard(window: &adw::Window, handle: &WorkspaceHandle, scope: CloseScope) -> Rc>> { + let decision = Rc::new(RefCell::new(None)); + let sink = decision.clone(); + let parent = window.clone().upcast::(); + let handle = handle.clone(); + gtk::glib::spawn_future_local(async move { + let decided = CloseGuard::run(&parent, &handle, scope).await; + sink.replace(Some(decided)); + }); + decision + } + + /// Wait for an alert with `heading` other than `seen`, and answer + /// it with `response`. Returns the alert answered. + fn answer_next( + window: &adw::Window, + heading: &str, + response: &str, + seen: Option<&adw::AlertDialog>, + ) -> adw::AlertDialog { + let mut shown = None; + wait_until(WAIT, || { + shown = visible_alert(window) + .filter(|alert| alert.heading().as_deref() == Some(heading) && Some(alert) != seen); + shown.is_some() + }) + .unwrap_or_else(|_| panic!("no “{heading}” alert")); + assert!(answer(window, response)); + shown.expect("the alert") + } + + fn decided(decision: &Rc>>) -> CloseDecision { + wait_until(WAIT, || decision.borrow().is_some()).expect("the guard to decide"); + decision.borrow().expect("a decision") + } + + fn rolled_back() -> DriverError { + DriverError::RolledBack { + statement_index: 0, + source: Box::new(DriverError::server("duplicate key")), + } + } + + #[gtk4::test] + fn cancel_keeps_window() { + let workspace = Workspace::open(ConnectionScript::default()); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Save Changes?", "cancel", None); + + assert_eq!(decided(&decision), CloseDecision::Cancelled); + assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 0); + } + + #[gtk4::test] + fn discard_closes() { + let workspace = Workspace::open(ConnectionScript::default()); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Save Changes?", "discard", None); + + assert_eq!(decided(&decision), CloseDecision::Approved); + assert_eq!(workspace.tracker.pending_state(), PendingState::Clean); + assert!(workspace.events.borrow().iter().any(|event| matches!( + event, + WorkspaceEvent::Discarded { tabs, reload: false } if tabs == &[workspace.tab] + ))); + } + + #[gtk4::test] + fn save_closes_once_the_save_lands() { + let mut script = ConnectionScript::default(); + script + .write_batch + .always(FakeReply::ok(WriteReport::new(vec![Some(1)]))); + let workspace = Workspace::open(script); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Save Changes?", "save", None); + + assert_eq!(decided(&decision), CloseDecision::Approved); + assert_eq!(workspace.tracker.pending_state(), PendingState::Clean); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); + } + + #[gtk4::test] + fn rolled_back_save_represents() { + let mut script = ConnectionScript::default(); + script.write_batch.always(FakeReply::err(rolled_back())); + let workspace = Workspace::open(script); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + let first = answer_next(&window, "Save Changes?", "save", None); + answer_next(&window, "Save Changes?", "cancel", Some(&first)); + + assert_eq!(decided(&decision), CloseDecision::Cancelled); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); + assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); + } + + #[gtk4::test] + fn in_flight_save_waiting_dialog_force_closes_on_settle() { + let mut script = ConnectionScript::default(); + script.write_batch.always(FakeReply::Bounded { + after: Duration::from_millis(500), + outcome: Ok(WriteReport::new(vec![Some(1)])), + }); + let workspace = Workspace::open(script); + let window = window(); + assert!(matches!( + workspace.handle.save(&[workspace.tab]).as_slice(), + [SaveStart::Started(_)] + )); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + wait_until(WAIT, || { + visible_alert(&window).is_some_and(|alert| alert.heading().as_deref() == Some("Saving Changes")) + }) + .expect("the waiting dialog"); + + assert_eq!(decided(&decision), CloseDecision::Approved); + crate::test_support::drain_main_context(); + assert!(visible_alert(&window).is_none(), "the waiting dialog stayed up"); + assert_eq!(workspace.tracker.pending_state(), PendingState::Clean); + } + + #[gtk4::test] + fn unknown_reload_cancels_close_and_write_batch_count_stays_one() { + let mut script = ConnectionScript::default(); + script + .write_batch + .always(FakeReply::err(DriverError::CommitOutcomeUnknown { + token: None, + source: Box::new(DriverError::ConnectionLost { + during: LossPhase::Commit, + }), + })); + let workspace = Workspace::open(script); + let window = window(); + workspace.save(); + assert!(matches!( + workspace.tracker.pending_state(), + PendingState::OutcomeUnknown(_) + )); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Save Result Unknown", "reload", None); + + assert_eq!(decided(&decision), CloseDecision::Cancelled); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); + assert_eq!(workspace.tracker.pending_state(), PendingState::Clean); + assert!( + workspace + .events + .borrow() + .iter() + .any(|event| matches!(event, WorkspaceEvent::Discarded { reload: true, .. })) + ); + } + + #[gtk4::test] + fn stop_saving_settles_and_reevaluates() { + let mut script = ConnectionScript::default(); + script + .write_batch + .always(FakeReply::StoppedWith(Err(DriverError::RolledBack { + statement_index: 0, + source: Box::new(DriverError::Cancelled), + }))); + let workspace = Workspace::open(script); + let window = window(); + assert!(matches!( + workspace.handle.save(&[workspace.tab]).as_slice(), + [SaveStart::Started(_)] + )); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Saving Changes", "stop", None); + answer_next(&window, "Save Changes?", "cancel", None); + + assert_eq!(decided(&decision), CloseDecision::Cancelled); + let observed = workspace.fake.connection.observed_cancels("write_batch"); + assert!( + observed.iter().all(|cancel| cancel.is_cancelled()), + "the write never saw the stop" + ); + assert_eq!(workspace.handle.ledger().in_flight(), 0); + assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); + assert!(visible_alert(&window).is_none(), "a waiting dialog stayed up"); + } + + #[gtk4::test] + fn window_close_asks_about_open_editor_transactions_once() { + let workspace = Workspace::open(ConnectionScript::default()); + workspace.open_transaction_tab(); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Close With Open Transactions?", "close", None); + answer_next(&window, "Save Changes?", "discard", None); + + assert_eq!(decided(&decision), CloseDecision::Approved); + } + + #[gtk4::test] + fn an_open_transaction_cancel_keeps_the_window() { + let workspace = Workspace::open(ConnectionScript::default()); + workspace.handle.discard(&[workspace.tab], false); + workspace.open_transaction_tab(); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Close With Open Transactions?", "cancel", None); + + assert_eq!(decided(&decision), CloseDecision::Cancelled); + } + + #[gtk4::test] + fn a_tab_close_asks_only_about_that_tab() { + let workspace = Workspace::open(ConnectionScript::default()); + let editor = workspace.open_transaction_tab(); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Tab(editor)); + answer_next(&window, "Close With Open Transactions?", "close", None); + + assert_eq!(decided(&decision), CloseDecision::Approved); + assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); + } +} diff --git a/linux/crates/app/src/workspace/close_scope.rs b/linux/crates/app/src/workspace/close_scope.rs new file mode 100644 index 0000000000..4185750a53 --- /dev/null +++ b/linux/crates/app/src/workspace/close_scope.rs @@ -0,0 +1,19 @@ +use uuid::Uuid; + +/// What is closing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloseScope { + /// Every tab the window holds: the window closing, or the workspace + /// going with a disconnect or a switch to another connection. + Window, + Tab(Uuid), +} + +impl CloseScope { + pub fn covers(self, tab: Uuid) -> bool { + match self { + Self::Window => true, + Self::Tab(id) => id == tab, + } + } +} diff --git a/linux/crates/app/src/workspace/close_step.rs b/linux/crates/app/src/workspace/close_step.rs new file mode 100644 index 0000000000..948bc2ba60 --- /dev/null +++ b/linux/crates/app/src/workspace/close_step.rs @@ -0,0 +1,97 @@ +use super::{CloseScope, DirtyKind, DirtyTab}; + +/// What a close has to settle next, in the order it settles them: a +/// save still running decides what the others are, and an outcome +/// nobody knows comes before anything is saved again. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CloseStep { + WaitForSaves, + ResolveUnknownOutcome(Vec), + ConfirmOpenTransactions(Vec), + AskSaveChanges(Vec), + Approve, +} + +/// The step a close in `scope` takes, given how many of its saves are +/// running and what its tabs would lose. +pub fn next_close_step(scope: CloseScope, saves_in_flight: usize, dirty: &[DirtyTab]) -> CloseStep { + if saves_in_flight > 0 { + return CloseStep::WaitForSaves; + } + let in_scope: Vec<&DirtyTab> = dirty.iter().filter(|tab| scope.covers(tab.id)).collect(); + let of_kind = |wanted: fn(&DirtyKind) -> bool| -> Vec { + in_scope + .iter() + .filter(|tab| wanted(&tab.kind)) + .map(|tab| (*tab).clone()) + .collect() + }; + let unknown = of_kind(|kind| matches!(kind, DirtyKind::OutcomeUnknown(_))); + if !unknown.is_empty() { + return CloseStep::ResolveUnknownOutcome(unknown); + } + let transactions = of_kind(|kind| matches!(kind, DirtyKind::OpenTransaction)); + if !transactions.is_empty() { + return CloseStep::ConfirmOpenTransactions(transactions); + } + let unsaved = of_kind(|kind| matches!(kind, DirtyKind::Unsaved)); + if !unsaved.is_empty() { + return CloseStep::AskSaveChanges(unsaved); + } + CloseStep::Approve +} + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::*; + use crate::workspace::UnknownCause; + + fn tab(kind: DirtyKind) -> DirtyTab { + DirtyTab { + id: Uuid::new_v4(), + title: "users".to_owned(), + kind, + } + } + + #[test] + fn next_close_step_table() { + let unsaved = tab(DirtyKind::Unsaved); + let unknown = tab(DirtyKind::OutcomeUnknown(UnknownCause::Interrupted)); + let transaction = tab(DirtyKind::OpenTransaction); + let all = [unsaved.clone(), transaction.clone(), unknown.clone()]; + let window = CloseScope::Window; + + assert_eq!(next_close_step(window, 1, &all), CloseStep::WaitForSaves); + assert_eq!(next_close_step(window, 1, &[]), CloseStep::WaitForSaves); + assert_eq!( + next_close_step(window, 0, &all), + CloseStep::ResolveUnknownOutcome(vec![unknown.clone()]) + ); + assert_eq!( + next_close_step(window, 0, &[unsaved.clone(), transaction.clone()]), + CloseStep::ConfirmOpenTransactions(vec![transaction.clone()]) + ); + assert_eq!( + next_close_step(window, 0, std::slice::from_ref(&unsaved)), + CloseStep::AskSaveChanges(vec![unsaved.clone()]) + ); + assert_eq!(next_close_step(window, 0, &[]), CloseStep::Approve); + + assert_eq!( + next_close_step(CloseScope::Tab(unsaved.id), 0, &all), + CloseStep::AskSaveChanges(vec![unsaved.clone()]), + "another tab's unknown outcome held up this tab's close" + ); + assert_eq!( + next_close_step(CloseScope::Tab(transaction.id), 0, &all), + CloseStep::ConfirmOpenTransactions(vec![transaction]) + ); + assert_eq!( + next_close_step(CloseScope::Tab(Uuid::new_v4()), 0, &all), + CloseStep::Approve + ); + } +} diff --git a/linux/crates/app/src/workspace/dialog_response.rs b/linux/crates/app/src/workspace/dialog_response.rs new file mode 100644 index 0000000000..998f408d10 --- /dev/null +++ b/linux/crates/app/src/workspace/dialog_response.rs @@ -0,0 +1,51 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use futures::channel::oneshot; +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; + +/// Present `dialog` on `parent` and wait for the response it closes +/// with. A dialog closed without one, torn down with its window or +/// forced shut, answers with its close response. +/// +/// `choose_future` is not used: its callback aborts the process when +/// the dialog goes away without a response. +pub(crate) async fn respond(dialog: &adw::AlertDialog, parent: >k::Widget) -> String { + let fallback = dialog.close_response().to_string(); + let (sender, receiver) = oneshot::channel(); + let sender = Rc::new(RefCell::new(Some(sender))); + let on_response = sender.clone(); + dialog.connect_response(None, move |_, response| { + if let Some(sender) = on_response.borrow_mut().take() { + let _ = sender.send(response.to_owned()); + } + }); + let closed_fallback = fallback.clone(); + dialog.connect_closed(move |_| { + if let Some(sender) = sender.borrow_mut().take() { + let _ = sender.send(closed_fallback.clone()); + } + }); + dialog.present(Some(parent)); + receiver.await.unwrap_or(fallback) +} + +/// Answer the alert `window` shows with `response`, the way a click +/// would: the response, then the close. +#[cfg(test)] +pub(crate) fn answer(window: &impl IsA, response: &str) -> bool { + let Some(dialog) = window.as_ref().visible_dialog().and_downcast::() else { + return false; + }; + dialog.emit_by_name::<()>("response", &[&response]); + dialog.force_close(); + true +} + +/// The alert `window` shows, if any. +#[cfg(test)] +pub(crate) fn visible_alert(window: &impl IsA) -> Option { + window.as_ref().visible_dialog().and_downcast::() +} diff --git a/linux/crates/app/src/workspace/dirty_kind.rs b/linux/crates/app/src/workspace/dirty_kind.rs new file mode 100644 index 0000000000..b07213e865 --- /dev/null +++ b/linux/crates/app/src/workspace/dirty_kind.rs @@ -0,0 +1,12 @@ +use super::UnknownCause; + +/// What closing a tab would lose. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DirtyKind { + Unsaved, + /// A save that may have landed, which the user has to look at. + OutcomeUnknown(UnknownCause), + /// An editor tab's transaction, which the server rolls back when its + /// connection ends. + OpenTransaction, +} diff --git a/linux/crates/app/src/workspace/dirty_tab.rs b/linux/crates/app/src/workspace/dirty_tab.rs new file mode 100644 index 0000000000..42b03b10a3 --- /dev/null +++ b/linux/crates/app/src/workspace/dirty_tab.rs @@ -0,0 +1,23 @@ +use uuid::Uuid; + +use super::DirtyKind; + +/// A tab closing would lose something in, named the way its tab is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirtyTab { + pub id: Uuid, + pub title: String, + pub kind: DirtyKind, +} + +impl DirtyTab { + /// The tabs' names for a sentence, each quoted. + pub fn names(tabs: &[DirtyTab]) -> String { + let quoted: Vec = tabs.iter().map(|tab| Self::quote(&tab.title)).collect(); + quoted.join(", ") + } + + pub fn quote(title: &str) -> String { + crate::i18n::gettext_f("“{name}”", &[("name", title)]) + } +} diff --git a/linux/crates/app/src/workspace/inhibit_reason.rs b/linux/crates/app/src/workspace/inhibit_reason.rs new file mode 100644 index 0000000000..7dfed00566 --- /dev/null +++ b/linux/crates/app/src/workspace/inhibit_reason.rs @@ -0,0 +1,36 @@ +use gtk4 as gtk; + +/// Why the session should not end under the window. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InhibitReason { + UnsavedChanges, + /// A write is running. Suspending would cut it off mid-commit. + SavingChanges, +} + +impl InhibitReason { + /// What the window holds now, or `None` when a logout loses + /// nothing. + pub fn for_state(dirty: bool, saving: bool) -> Option { + match (dirty, saving) { + (_, true) => Some(Self::SavingChanges), + (true, false) => Some(Self::UnsavedChanges), + (false, false) => None, + } + } + + pub fn flags(self) -> gtk::ApplicationInhibitFlags { + match self { + Self::UnsavedChanges => gtk::ApplicationInhibitFlags::LOGOUT, + Self::SavingChanges => gtk::ApplicationInhibitFlags::LOGOUT | gtk::ApplicationInhibitFlags::SUSPEND, + } + } + + /// Shown by the session when it asks the user to wait. + pub fn text(self) -> String { + match self { + Self::UnsavedChanges => crate::i18n::gettext("There are unsaved changes"), + Self::SavingChanges => crate::i18n::gettext("Saving changes"), + } + } +} diff --git a/linux/crates/app/src/workspace/mod.rs b/linux/crates/app/src/workspace/mod.rs index 7532bdd637..ac3aa031cc 100644 --- a/linux/crates/app/src/workspace/mod.rs +++ b/linux/crates/app/src/workspace/mod.rs @@ -1,12 +1,45 @@ -//! What the workspace keeps between sessions: which tabs were open and -//! what the editor ones held. +//! A window's workspace: which tabs were open and what the editor ones +//! held, kept between sessions, and the saves its tabs run and the +//! prompts that decide whether they may close. +mod close_decision; +mod close_guard; +mod close_scope; +mod close_step; +mod dialog_response; +mod dirty_kind; +mod dirty_tab; mod draft_writer; +mod inhibit_reason; +mod open_transaction_dialog; +mod pending_state; mod persist_debouncer; +mod ran_statements; mod restored_drafts; +mod rollback_cause; +mod save_changes_dialog; +mod save_id; +mod save_ledger; +mod save_outcome; +mod save_rejection; +mod save_settled; +mod save_snapshot; +mod save_start; +mod save_ticket; +mod schema_write; mod sort_record; +mod tab_directory; +mod tab_entry; mod tab_record; +#[cfg(test)] +mod test_workspace; +mod unknown_cause; +mod unknown_outcome_dialog; +mod waiting_dialog; +mod window_inhibitor; +mod workspace_event; mod workspace_file_name; +mod workspace_handle; mod workspace_load; mod workspace_load_error; mod workspace_record; @@ -14,12 +47,37 @@ mod workspace_save_error; mod workspace_store; mod write_job; +pub use close_decision::CloseDecision; +pub(crate) use close_guard::CloseGuard; +pub use close_scope::CloseScope; +pub use close_step::{CloseStep, next_close_step}; +pub use dirty_kind::DirtyKind; +pub use dirty_tab::DirtyTab; pub use draft_writer::DraftWriter; +pub use inhibit_reason::InhibitReason; +pub use pending_state::PendingState; pub use persist_debouncer::PersistDebouncer; +pub(crate) use ran_statements::RanStatements; pub use restored_drafts::RestoredDrafts; +pub use rollback_cause::RollbackCause; +pub use save_id::SaveId; +pub use save_ledger::SaveLedger; +pub use save_outcome::SaveOutcome; +pub use save_rejection::SaveRejection; +pub use save_settled::SaveSettled; +pub use save_snapshot::SaveSnapshot; +pub use save_start::SaveStart; +pub use save_ticket::SaveTicket; +pub(crate) use schema_write::run_schema_plan; pub use sort_record::SortRecord; +pub use tab_directory::TabDirectory; +pub use tab_entry::TabEntry; pub use tab_record::TabRecord; +pub use unknown_cause::UnknownCause; +pub use window_inhibitor::{ApplicationInhibit, WindowInhibitor}; +pub use workspace_event::WorkspaceEvent; pub use workspace_file_name::WorkspaceFileName; +pub use workspace_handle::{WorkspaceDeps, WorkspaceHandle}; pub use workspace_load::WorkspaceLoad; pub use workspace_load_error::WorkspaceLoadError; pub use workspace_record::WorkspaceRecord; diff --git a/linux/crates/app/src/workspace/open_transaction_dialog.rs b/linux/crates/app/src/workspace/open_transaction_dialog.rs new file mode 100644 index 0000000000..0bf47d304e --- /dev/null +++ b/linux/crates/app/src/workspace/open_transaction_dialog.rs @@ -0,0 +1,27 @@ +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; + +use super::DirtyTab; +use super::dialog_response::respond; + +const CANCEL: &str = "cancel"; +const CLOSE: &str = "close"; + +/// Whether the user agrees to end the editor connections of `tabs`, +/// whose open transactions the server then rolls back. +pub(crate) async fn confirm_open_transactions(parent: >k::Widget, tabs: &[DirtyTab]) -> bool { + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Close With Open Transactions?")), + Some(&crate::i18n::gettext_f( + "Closing ends the database sessions of {tabs}. The server rolls back uncommitted work.", + &[("tabs", &DirtyTab::names(tabs))], + )), + ); + dialog.add_response(CANCEL, &crate::i18n::gettext("_Cancel")); + dialog.add_response(CLOSE, &crate::i18n::gettext("_Close Anyway")); + dialog.set_response_appearance(CLOSE, adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some(CANCEL)); + dialog.set_close_response(CANCEL); + respond(&dialog, parent).await == CLOSE +} diff --git a/linux/crates/app/src/workspace/pending_state.rs b/linux/crates/app/src/workspace/pending_state.rs new file mode 100644 index 0000000000..c9739e9538 --- /dev/null +++ b/linux/crates/app/src/workspace/pending_state.rs @@ -0,0 +1,12 @@ +use super::UnknownCause; + +/// Where a tab's tracker stands with its unsaved changes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PendingState { + Clean, + Unsaved, + Saving, + /// A save may have landed. The tracker refuses to save again until + /// the tab is reloaded or its changes are discarded. + OutcomeUnknown(UnknownCause), +} diff --git a/linux/crates/app/src/ui/app/ran_statements.rs b/linux/crates/app/src/workspace/ran_statements.rs similarity index 96% rename from linux/crates/app/src/ui/app/ran_statements.rs rename to linux/crates/app/src/workspace/ran_statements.rs index 241058dc89..984be6f058 100644 --- a/linux/crates/app/src/ui/app/ran_statements.rs +++ b/linux/crates/app/src/workspace/ran_statements.rs @@ -13,7 +13,7 @@ use tablepro_storage::query_history::{NewEntry, Outcome}; /// before the work starts, because the connection can be gone by the /// time the answer comes back. #[derive(Debug, Clone)] -pub(super) struct RanStatements { +pub(crate) struct RanStatements { history: Option, connection: Option, sql: String, @@ -23,7 +23,7 @@ pub(super) struct RanStatements { impl RanStatements { /// Capture the batch about to run. `sql` reads as one entry in the /// history list, which is how the user thinks of one save. - pub(super) fn starting( + pub(crate) fn starting( history: Option, connection: Option, statements: impl IntoIterator, @@ -40,7 +40,7 @@ impl RanStatements { /// the batch was empty, and never fails the save it describes: a /// history that cannot be written is worth a log line, not a lost /// edit. - pub(super) async fn finished(self, rows_affected: Option, outcome: Outcome) { + pub(crate) async fn finished(self, rows_affected: Option, outcome: Outcome) { let (Some(history), Some(connection)) = (self.history, self.connection) else { return; }; diff --git a/linux/crates/app/src/workspace/rollback_cause.rs b/linux/crates/app/src/workspace/rollback_cause.rs new file mode 100644 index 0000000000..b1945c24e2 --- /dev/null +++ b/linux/crates/app/src/workspace/rollback_cause.rs @@ -0,0 +1,36 @@ +use tablepro_core::DriverError; +use tablepro_core::browse::BrowseSqlError; +use tablepro_core::dml::BuildSqlError; +use tablepro_core::statement::SqlBuildError; +use tablepro_session::OperationError; + +/// Why a save left the database as it was. +#[derive(Debug, Clone, PartialEq)] +pub enum RollbackCause { + /// The server refused it, or the driver undid it. + Driver(DriverError), + /// It could not be written as SQL, so nothing was sent. + Sql(SqlBuildError), + Build(BuildSqlError), + Browse(BrowseSqlError), + /// The commit's answer was lost, and the server said afterwards that + /// it never landed. + NotApplied, +} + +impl RollbackCause { + /// What to tell the user. + pub fn message(&self) -> String { + match self { + Self::Driver(error) => crate::ui::error_text::driver_message(error), + Self::Sql(error) => crate::ui::error_text::operation_error_message(&OperationError::Sql(error.clone())), + Self::Build(error) => crate::ui::error_text::operation_error_message(&OperationError::Build(error.clone())), + Self::Browse(error) => { + crate::ui::error_text::operation_error_message(&OperationError::Browse(error.clone())) + } + Self::NotApplied => crate::i18n::gettext( + "The connection was lost while saving, and the database says the changes were not saved.", + ), + } + } +} diff --git a/linux/crates/app/src/workspace/save_changes_dialog.rs b/linux/crates/app/src/workspace/save_changes_dialog.rs new file mode 100644 index 0000000000..7d2661b702 --- /dev/null +++ b/linux/crates/app/src/workspace/save_changes_dialog.rs @@ -0,0 +1,77 @@ +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; +use uuid::Uuid; + +use super::DirtyTab; +use super::dialog_response::respond; + +const CANCEL: &str = "cancel"; +const DISCARD: &str = "discard"; +const SAVE: &str = "save"; + +/// What the user chose for tabs with unsaved changes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SaveChoice { + Cancel, + /// Save the tabs that stayed checked and drop the rest. + Proceed { + save: Vec, + discard: Vec, + }, +} + +/// Ask which of `tabs` to save, as GNOME Text Editor does: every tab +/// listed with a check box, all checked. +pub(crate) async fn ask_save_changes(parent: >k::Widget, tabs: &[DirtyTab]) -> SaveChoice { + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Save Changes?")), + Some(&crate::i18n::gettext( + "Changes that are not saved are permanently lost.", + )), + ); + let group = adw::PreferencesGroup::new(); + let checks: Vec<(Uuid, gtk::CheckButton)> = tabs + .iter() + .map(|tab| { + let check = gtk::CheckButton::builder() + .active(true) + .valign(gtk::Align::Center) + .build(); + let row = adw::ActionRow::builder().title(&tab.title).use_markup(false).build(); + row.add_prefix(&check); + row.set_activatable_widget(Some(&check)); + let label: >k::Widget = row.upcast_ref(); + check.update_relation(&[gtk::accessible::Relation::LabelledBy(&[label.upcast_ref()])]); + group.add(&row); + (tab.id, check) + }) + .collect(); + dialog.set_extra_child(Some(&group)); + dialog.set_prefer_wide_layout(tabs.len() > 1); + let discard_label = match tabs.len() { + 1 => crate::i18n::gettext("_Discard"), + _ => crate::i18n::gettext("_Discard All"), + }; + dialog.add_response(CANCEL, &crate::i18n::gettext("_Cancel")); + dialog.add_response(DISCARD, &discard_label); + dialog.add_response(SAVE, &crate::i18n::gettext("_Save")); + dialog.set_response_appearance(DISCARD, adw::ResponseAppearance::Destructive); + dialog.set_response_appearance(SAVE, adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some(CANCEL)); + dialog.set_close_response(CANCEL); + match respond(&dialog, parent).await.as_str() { + SAVE => { + let (save, discard): (Vec<_>, Vec<_>) = checks.iter().partition(|(_, check)| check.is_active()); + SaveChoice::Proceed { + save: save.into_iter().map(|(id, _)| *id).collect(), + discard: discard.into_iter().map(|(id, _)| *id).collect(), + } + } + DISCARD => SaveChoice::Proceed { + save: Vec::new(), + discard: tabs.iter().map(|tab| tab.id).collect(), + }, + _ => SaveChoice::Cancel, + } +} diff --git a/linux/crates/app/src/workspace/save_id.rs b/linux/crates/app/src/workspace/save_id.rs new file mode 100644 index 0000000000..d9e3a32ab3 --- /dev/null +++ b/linux/crates/app/src/workspace/save_id.rs @@ -0,0 +1,9 @@ +/// One save from the moment it starts to the moment it settles. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SaveId(u64); + +impl SaveId { + pub(super) fn new(value: u64) -> Self { + Self(value) + } +} diff --git a/linux/crates/app/src/workspace/save_ledger.rs b/linux/crates/app/src/workspace/save_ledger.rs new file mode 100644 index 0000000000..dfa2f7041e --- /dev/null +++ b/linux/crates/app/src/workspace/save_ledger.rs @@ -0,0 +1,283 @@ +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::future::Future; +use std::rc::Rc; + +use futures::channel::oneshot; +use tablepro_core::WriteKind; +use tablepro_session::runtime::{HoldToken, ProcessHold}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::{CloseScope, SaveId, SaveTicket}; + +/// The saves a window has running, so closing it can wait for them or +/// stop them rather than poll. +/// +/// Each running save holds the process, so quitting while one runs +/// still lets it settle and report. +#[derive(Default)] +pub struct SaveLedger { + state: RefCell, + listeners: RefCell>, +} + +type Listener = Rc; + +#[derive(Default)] +struct LedgerState { + next: u64, + entries: BTreeMap, + waiters: Vec<(CloseScope, oneshot::Sender<()>)>, +} + +struct Entry { + tab: Uuid, + kind: WriteKind, + cancel: CancellationToken, + _hold: HoldToken, +} + +impl SaveLedger { + pub fn new() -> Self { + Self::default() + } + + /// Record a save of `tab`, or `None` when one of the same kind is + /// already running for it: a second would commit the same changes + /// twice. + pub fn begin(&self, hold: &dyn ProcessHold, tab: Uuid, kind: WriteKind) -> Option { + let ticket = { + let mut state = self.state.borrow_mut(); + if state + .entries + .values() + .any(|entry| entry.tab == tab && entry.kind == kind) + { + return None; + } + state.next += 1; + let id = SaveId::new(state.next); + let cancel = CancellationToken::new(); + state.entries.insert( + id, + Entry { + tab, + kind, + cancel: cancel.clone(), + _hold: hold.acquire(), + }, + ); + SaveTicket { id, cancel } + }; + self.notify(); + Some(ticket) + } + + /// The save ended. A second call for the same save does nothing and + /// returns false. + pub fn settle(&self, id: SaveId) -> bool { + let (entry, woken) = { + let mut state = self.state.borrow_mut(); + let Some(entry) = state.entries.remove(&id) else { + return false; + }; + let waiters = std::mem::take(&mut state.waiters); + let (idle, waiting): (Vec<_>, Vec<_>) = waiters + .into_iter() + .partition(|(scope, _)| !state.entries.values().any(|entry| scope.covers(entry.tab))); + state.waiters = waiting; + (entry, idle) + }; + // The hold goes only after the state is consistent, in case + // dropping it ends the main loop. + drop(entry); + for (_, waiter) in woken { + let _ = waiter.send(()); + } + self.notify(); + true + } + + pub fn in_flight(&self) -> usize { + self.state.borrow().entries.len() + } + + /// Saves running for the tabs `scope` covers. + pub fn in_flight_in(&self, scope: CloseScope) -> usize { + self.state + .borrow() + .entries + .values() + .filter(|entry| scope.covers(entry.tab)) + .count() + } + + /// Resolves once no save `scope` covers is running. + pub fn when_idle(&self, scope: CloseScope) -> impl Future + use<> { + let receiver = if self.in_flight_in(scope) == 0 { + None + } else { + let (sender, receiver) = oneshot::channel(); + self.state.borrow_mut().waiters.push((scope, sender)); + Some(receiver) + }; + async move { + if let Some(receiver) = receiver { + // A ledger dropped with saves running wakes nobody, and + // a waiter that outlived it has nothing left to wait on. + let _ = receiver.await; + } + } + } + + /// Stop the saves `scope` covers. Each settles when its driver + /// answers the cancel. + pub fn cancel(&self, scope: CloseScope) { + for entry in self.state.borrow().entries.values() { + if scope.covers(entry.tab) { + entry.cancel.cancel(); + } + } + } + + /// Call `listener` with the number of saves running whenever it + /// changes. + pub fn on_change(&self, listener: impl Fn(usize) + 'static) { + self.listeners.borrow_mut().push(Rc::new(listener)); + } + + fn notify(&self) { + let count = self.in_flight(); + let listeners: Vec = self.listeners.borrow().clone(); + for listener in listeners { + listener(count); + } + } +} + +impl std::fmt::Debug for SaveLedger { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SaveLedger") + .field("in_flight", &self.in_flight()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::pin::{Pin, pin}; + use std::task::{Context, Poll, Waker}; + + use tablepro_session::runtime::CountingHold; + + use super::*; + + fn ready(future: impl Future) -> bool { + polled(pin!(future)) + } + + fn polled(future: Pin<&mut impl Future>) -> bool { + matches!(future.poll(&mut Context::from_waker(Waker::noop())), Poll::Ready(())) + } + + #[test] + fn settle_idempotent() { + let ledger = SaveLedger::new(); + let hold = CountingHold::new(); + let changes = Rc::new(Cell::new(0)); + let seen = changes.clone(); + ledger.on_change(move |_| seen.set(seen.get() + 1)); + let ticket = ledger + .begin(&hold, Uuid::new_v4(), WriteKind::RowChanges) + .expect("a ticket"); + + assert!(ledger.settle(ticket.id)); + assert!(!ledger.settle(ticket.id), "a second settle counted again"); + assert_eq!(ledger.in_flight(), 0); + assert_eq!(changes.get(), 2, "a second settle reported a change"); + } + + #[test] + fn when_idle_resolves_after_last_settle() { + let ledger = SaveLedger::new(); + let hold = CountingHold::new(); + let first = ledger + .begin(&hold, Uuid::new_v4(), WriteKind::RowChanges) + .expect("a ticket"); + let second = ledger + .begin(&hold, Uuid::new_v4(), WriteKind::SchemaChanges) + .expect("a ticket"); + let mut idle = pin!(ledger.when_idle(CloseScope::Window)); + + assert!(!polled(idle.as_mut())); + ledger.settle(first.id); + assert!(!polled(idle.as_mut()), "idle with a save still running"); + ledger.settle(second.id); + assert!(polled(idle.as_mut())); + assert!( + ready(ledger.when_idle(CloseScope::Window)), + "an idle ledger kept a waiter waiting" + ); + } + + #[test] + fn when_tab_idle_waits_only_for_that_tab() { + let ledger = SaveLedger::new(); + let hold = CountingHold::new(); + let (tab, other) = (Uuid::new_v4(), Uuid::new_v4()); + let mine = ledger.begin(&hold, tab, WriteKind::RowChanges).expect("a ticket"); + let _theirs = ledger.begin(&hold, other, WriteKind::RowChanges).expect("a ticket"); + let idle = pin!(ledger.when_idle(CloseScope::Tab(tab))); + + ledger.settle(mine.id); + + assert!(polled(idle)); + assert_eq!(ledger.in_flight_in(CloseScope::Tab(other)), 1); + } + + #[test] + fn begin_refuses_duplicate_kind() { + let ledger = SaveLedger::new(); + let hold = CountingHold::new(); + let tab = Uuid::new_v4(); + + let first = ledger.begin(&hold, tab, WriteKind::RowChanges).expect("a ticket"); + assert!(ledger.begin(&hold, tab, WriteKind::RowChanges).is_none()); + assert!(ledger.begin(&hold, tab, WriteKind::SchemaChanges).is_some()); + ledger.settle(first.id); + assert!(ledger.begin(&hold, tab, WriteKind::RowChanges).is_some()); + } + + #[test] + fn cancel_scope_cancels_only_scoped_tokens() { + let ledger = SaveLedger::new(); + let hold = CountingHold::new(); + let (tab, other) = (Uuid::new_v4(), Uuid::new_v4()); + let mine = ledger.begin(&hold, tab, WriteKind::RowChanges).expect("a ticket"); + let theirs = ledger.begin(&hold, other, WriteKind::RowChanges).expect("a ticket"); + + ledger.cancel(CloseScope::Tab(tab)); + assert!(mine.cancel.is_cancelled()); + assert!(!theirs.cancel.is_cancelled()); + + ledger.cancel(CloseScope::Window); + assert!(theirs.cancel.is_cancelled()); + } + + #[test] + fn entry_holds_counting_hold_until_settle() { + let ledger = SaveLedger::new(); + let hold = CountingHold::new(); + let ticket = ledger + .begin(&hold, Uuid::new_v4(), WriteKind::RowChanges) + .expect("a ticket"); + + assert_eq!(hold.live(), 1); + ledger.cancel(CloseScope::Window); + assert_eq!(hold.live(), 1, "a stopped save let go of the process before it settled"); + ledger.settle(ticket.id); + assert_eq!(hold.live(), 0); + } +} diff --git a/linux/crates/app/src/workspace/save_outcome.rs b/linux/crates/app/src/workspace/save_outcome.rs new file mode 100644 index 0000000000..f2b4c71743 --- /dev/null +++ b/linux/crates/app/src/workspace/save_outcome.rs @@ -0,0 +1,176 @@ +use tablepro_core::{DriverError, ErrorCategory, WriteReport}; +use tablepro_session::OperationError; + +use super::{RollbackCause, UnknownCause}; + +/// What a save left in the database, which decides what its tracker +/// does next. +#[derive(Debug, Clone, PartialEq)] +pub enum SaveOutcome { + /// Every statement landed. `rows` is the total the server reported, + /// when every statement reported one. + Committed { rows: Option }, + /// Nothing landed. + RolledBack(RollbackCause), + /// It may have landed, and only the server can say. + Unknown(UnknownCause), +} + +impl SaveOutcome { + /// Once a statement is sent, a driver reports every failure as + /// rolled back, partly applied or of unknown outcome. A bare error + /// in a category that can strike mid-commit cannot be trusted to + /// mean nothing landed; any other bare error means nothing was sent. + pub fn classify(result: Result) -> Self { + match result { + Ok(report) => Self::Committed { + rows: report.total_rows(), + }, + Err(OperationError::Driver(error)) => Self::from_driver(error), + Err(OperationError::Sql(error)) => Self::RolledBack(RollbackCause::Sql(error)), + Err(OperationError::Build(error)) => Self::RolledBack(RollbackCause::Build(error)), + Err(OperationError::Browse(error)) => Self::RolledBack(RollbackCause::Browse(error)), + Err(OperationError::Cancelled) => Self::Unknown(UnknownCause::Interrupted), + Err(OperationError::Panicked(_)) => Self::Unknown(UnknownCause::Panicked), + Err(OperationError::Closed) => Self::Unknown(UnknownCause::Closed), + } + } + + fn from_driver(error: DriverError) -> Self { + match error { + DriverError::RolledBack { .. } | DriverError::RowGuardFailed { .. } | DriverError::ReadOnly(_) => { + Self::RolledBack(RollbackCause::Driver(error)) + } + DriverError::CommitOutcomeUnknown { token, .. } => Self::Unknown(UnknownCause::CommitAckLost { token }), + DriverError::PartiallyApplied { .. } => Self::Unknown(UnknownCause::PartiallyApplied), + other => match other.category() { + ErrorCategory::Interrupted | ErrorCategory::Timeout | ErrorCategory::ConnectionLost => { + Self::Unknown(UnknownCause::Interrupted) + } + _ => Self::RolledBack(RollbackCause::Driver(other)), + }, + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::dml::BuildSqlError; + use tablepro_core::statement::SqlBuildError; + use tablepro_core::{CommitToken, LossPhase, ReadOnlyRefusal, StatementOutcome, TimeoutPhase, TransportError}; + use tablepro_session::runtime::TaskPanic; + + use super::*; + + fn driver(error: DriverError) -> SaveOutcome { + SaveOutcome::classify(Err(OperationError::Driver(error))) + } + + fn rolled_back(outcome: &SaveOutcome) -> bool { + matches!(outcome, SaveOutcome::RolledBack(_)) + } + + #[test] + fn classify_maps_every_contract_case() { + let token = CommitToken::PostgresXid { + xid: 7, + legacy_txid: false, + }; + + assert_eq!( + SaveOutcome::classify(Ok(WriteReport::new(vec![Some(2), Some(1)]))), + SaveOutcome::Committed { rows: Some(3) } + ); + for error in [ + DriverError::RolledBack { + statement_index: 0, + source: Box::new(DriverError::server("duplicate key")), + }, + DriverError::RolledBack { + statement_index: 1, + source: Box::new(DriverError::Cancelled), + }, + DriverError::RolledBack { + statement_index: 1, + source: Box::new(DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: true, + }), + }, + DriverError::RowGuardFailed { + statement_index: 0, + expected: 1, + actual: 0, + }, + DriverError::ReadOnly(ReadOnlyRefusal::ClientGuard), + ] { + assert!(rolled_back(&driver(error.clone())), "{error:?}"); + } + assert_eq!( + driver(DriverError::CommitOutcomeUnknown { + token: Some(token.clone()), + source: Box::new(DriverError::ConnectionLost { + during: LossPhase::Commit + }), + }), + SaveOutcome::Unknown(UnknownCause::CommitAckLost { token: Some(token) }) + ); + assert_eq!( + driver(DriverError::PartiallyApplied { + applied: 1, + statement_index: 1, + failed_outcome: StatementOutcome::Unknown, + source: Box::new(DriverError::Cancelled), + }), + SaveOutcome::Unknown(UnknownCause::PartiallyApplied) + ); + for interrupted in [ + DriverError::Cancelled, + DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: false, + }, + DriverError::ConnectionLost { + during: LossPhase::Statement, + }, + ] { + assert_eq!( + driver(interrupted.clone()), + SaveOutcome::Unknown(UnknownCause::Interrupted), + "{interrupted:?}" + ); + } + assert_eq!( + SaveOutcome::classify(Err(OperationError::Cancelled)), + SaveOutcome::Unknown(UnknownCause::Interrupted) + ); + for refused in [ + DriverError::Busy, + DriverError::server("syntax error"), + DriverError::auth(None), + DriverError::Transport(TransportError::NameResolution { + host: "db".to_owned(), + detail: "no such host".to_owned(), + }), + DriverError::Protocol("garbled".to_owned()), + ] { + assert!(rolled_back(&driver(refused.clone())), "{refused:?}"); + } + assert_eq!( + SaveOutcome::classify(Err(OperationError::Sql(SqlBuildError::Ddl("no".to_owned())))), + SaveOutcome::RolledBack(RollbackCause::Sql(SqlBuildError::Ddl("no".to_owned()))) + ); + assert_eq!( + SaveOutcome::classify(Err(OperationError::Build(BuildSqlError::NoRowIdentity))), + SaveOutcome::RolledBack(RollbackCause::Build(BuildSqlError::NoRowIdentity)) + ); + assert_eq!( + SaveOutcome::classify(Err(OperationError::Panicked(TaskPanic::from_payload(Box::new("boom"))))), + SaveOutcome::Unknown(UnknownCause::Panicked) + ); + assert_eq!( + SaveOutcome::classify(Err(OperationError::Closed)), + SaveOutcome::Unknown(UnknownCause::Closed) + ); + } +} diff --git a/linux/crates/app/src/workspace/save_rejection.rs b/linux/crates/app/src/workspace/save_rejection.rs new file mode 100644 index 0000000000..eb514c7339 --- /dev/null +++ b/linux/crates/app/src/workspace/save_rejection.rs @@ -0,0 +1,27 @@ +/// Why a save did not start. Nothing was sent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SaveRejection { + /// The last save's outcome is unknown, and saving again could apply + /// the same changes twice. + OutcomeUnknown, + AlreadySaving, + ReadOnly, + /// The changes cannot be written as they stand. + Invalid(String), + NoConnection, +} + +impl SaveRejection { + /// What to tell the user. + pub fn message(&self) -> String { + match self { + Self::OutcomeUnknown => crate::i18n::gettext( + "The last save may already be in the database. Discard these changes to see what the table holds now.", + ), + Self::AlreadySaving => crate::i18n::gettext("These changes are already being saved."), + Self::ReadOnly => crate::i18n::gettext("This connection is read-only."), + Self::Invalid(reason) => reason.clone(), + Self::NoConnection => crate::i18n::gettext("No active connection."), + } + } +} diff --git a/linux/crates/app/src/workspace/save_settled.rs b/linux/crates/app/src/workspace/save_settled.rs new file mode 100644 index 0000000000..8d52dd0b98 --- /dev/null +++ b/linux/crates/app/src/workspace/save_settled.rs @@ -0,0 +1,19 @@ +use tablepro_core::WriteKind; +use uuid::Uuid; + +use super::SaveOutcome; +use crate::services::change_tracker::StatementSource; + +/// A save that ended, after its tracker and the ledger took the outcome +/// in, for the tab to show. +#[derive(Debug, Clone)] +pub struct SaveSettled { + pub tab: Uuid, + pub kind: WriteKind, + pub outcome: SaveOutcome, + /// The grid row behind the statement a row save stopped at. + pub failing: Option, + /// The name a structure save leaves the table with, when it created + /// or renamed one. + pub saved_table_name: Option, +} diff --git a/linux/crates/app/src/workspace/save_snapshot.rs b/linux/crates/app/src/workspace/save_snapshot.rs new file mode 100644 index 0000000000..c38b49ad2b --- /dev/null +++ b/linux/crates/app/src/workspace/save_snapshot.rs @@ -0,0 +1,13 @@ +use crate::services::touch::Touch; + +/// Where a save's edits end: every edit touched before this one is in +/// the save, and an edit touched at or after it was made while the save +/// ran, so a commit leaves it pending. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SaveSnapshot(pub Touch); + +impl SaveSnapshot { + pub fn includes(self, touched: Touch) -> bool { + touched < self.0 + } +} diff --git a/linux/crates/app/src/workspace/save_start.rs b/linux/crates/app/src/workspace/save_start.rs new file mode 100644 index 0000000000..6d55c01f40 --- /dev/null +++ b/linux/crates/app/src/workspace/save_start.rs @@ -0,0 +1,9 @@ +use super::{SaveId, SaveRejection}; + +/// What asking a tab to save did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SaveStart { + Started(SaveId), + NothingToSave, + Rejected(SaveRejection), +} diff --git a/linux/crates/app/src/workspace/save_ticket.rs b/linux/crates/app/src/workspace/save_ticket.rs new file mode 100644 index 0000000000..ba73765611 --- /dev/null +++ b/linux/crates/app/src/workspace/save_ticket.rs @@ -0,0 +1,11 @@ +use tokio_util::sync::CancellationToken; + +use super::SaveId; + +/// What a started save runs under: its id, to settle it by, and the +/// token Stop Saving fires. +#[derive(Debug, Clone)] +pub struct SaveTicket { + pub id: SaveId, + pub cancel: CancellationToken, +} diff --git a/linux/crates/app/src/workspace/schema_write.rs b/linux/crates/app/src/workspace/schema_write.rs new file mode 100644 index 0000000000..ac0d70549e --- /dev/null +++ b/linux/crates/app/src/workspace/schema_write.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use tablepro_core::ddl::{BuildDdlError, DdlDialect, DdlOperation, DdlPlan}; +use tablepro_core::meta::TableRef; +use tablepro_core::statement::SqlBuildError; +use tablepro_core::{WriteKind, WriteReport}; +use tablepro_session::{OperationError, OperationKind, Session}; +use tokio_util::sync::CancellationToken; + +/// Plan a schema change with the dialect of the connection that runs +/// it, and run the plan as one write. The statements come back whether +/// or not the write went through, for the history. `operation` is what +/// a connection with no DDL dialect refuses. +pub(crate) async fn run_schema_plan

( + session: Arc, + table: TableRef, + operation: DdlOperation, + cancel: &CancellationToken, + plan: P, +) -> Result<(Vec, Result), OperationError> +where + P: Fn(&dyn DdlDialect, &TableRef) -> Result + Send + Sync, +{ + session + .run_cancellable( + OperationKind::Save(WriteKind::SchemaChanges), + cancel, + move |lease, options| { + let table = table.clone(); + let dialect = lease.dialect(); + let planned = match dialect.ddl() { + Some(ddl) => plan(ddl, &table).map_err(SqlBuildError::from), + None => Err(SqlBuildError::from(BuildDdlError::Unsupported(operation))), + }; + async move { + let plan = planned?; + let statements: Vec = plan.sql().into_iter().map(str::to_owned).collect(); + let written = lease.write_batch(&plan.into_write_batch(table), options).await; + Ok((statements, written.map_err(OperationError::from))) + } + }, + ) + .await +} diff --git a/linux/crates/app/src/workspace/tab_directory.rs b/linux/crates/app/src/workspace/tab_directory.rs new file mode 100644 index 0000000000..12a1ed1e86 --- /dev/null +++ b/linux/crates/app/src/workspace/tab_directory.rs @@ -0,0 +1,8 @@ +use super::TabEntry; + +/// What the workspace needs from the window's tabs that no tracker +/// holds: their order, their names and their open transactions. +pub trait TabDirectory { + /// Every open tab, in the order the tab bar shows them. + fn tabs(&self) -> Vec; +} diff --git a/linux/crates/app/src/workspace/tab_entry.rs b/linux/crates/app/src/workspace/tab_entry.rs new file mode 100644 index 0000000000..a10f1b133a --- /dev/null +++ b/linux/crates/app/src/workspace/tab_entry.rs @@ -0,0 +1,11 @@ +use uuid::Uuid; + +/// One open tab, as the close prompts name it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TabEntry { + pub id: Uuid, + /// The tab's title without the unsaved-changes bullet. + pub title: String, + /// The tab's editor connection holds a transaction the user opened. + pub open_transaction: bool, +} diff --git a/linux/crates/app/src/workspace/test_workspace.rs b/linux/crates/app/src/workspace/test_workspace.rs new file mode 100644 index 0000000000..84b7ef59e0 --- /dev/null +++ b/linux/crates/app/src/workspace/test_workspace.rs @@ -0,0 +1,171 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType, IntegerKind, IntegerWidth}; +use tablepro_core::meta::TableRef; +use tablepro_core::{ColumnInfo, Value}; +use tablepro_session::runtime::CountingHold; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; +use uuid::Uuid; + +use super::{SaveSettled, SaveStart, TabDirectory, TabEntry, WorkspaceDeps, WorkspaceEvent, WorkspaceHandle}; +use crate::services::active_session::ActiveSession; +use crate::services::change_tracker::RowKey; +use crate::services::change_tracker_registry::ChangeTrackerRegistry; +use crate::services::save_batch::SaveTarget; +use crate::services::structure_tracker_registry::StructureTrackerRegistry; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::{FakeSession, wait_until}; + +pub(crate) const WAIT: Duration = Duration::from_secs(10); + +/// The tabs a test window has open. +#[derive(Default)] +pub(crate) struct Tabs(pub RefCell>); + +impl TabDirectory for Tabs { + fn tabs(&self) -> Vec { + self.0.borrow().clone() + } +} + +pub(crate) fn integer(name: &str, primary_key: bool) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text("integer"); + column_type.kind = ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }); + ColumnInfo { + name: name.to_owned(), + column_type, + nullable: false, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } +} + +pub(crate) fn sqlite() -> Arc { + Arc::new(drivers_sqlite::SqliteDialect::new(drivers_sqlite::SqliteVersion::new( + 3, 45, 0, + ))) +} + +/// A workspace with one table tab holding one edited cell, over a fake +/// connection that writes as its script says. +pub(crate) struct Workspace { + pub handle: WorkspaceHandle, + pub tab: Uuid, + pub tracker: TabTrackerHandle, + pub tabs: Rc, + pub settled: Rc>>, + pub events: Rc>>, + pub fake: FakeSession, + pub hold: CountingHold, + runtime: Option, +} + +impl Workspace { + pub(crate) fn open(mut script: ConnectionScript) -> Self { + script.dialect = sqlite(); + script.probe.always(FakeReply::ok(())); + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + let active = ActiveSession::new(); + active.replace(fake.session.clone()); + let rows = Rc::new(ChangeTrackerRegistry::default()); + let tab = Uuid::new_v4(); + let tracker = rows.register(tab); + tracker.set_target(SaveTarget { + table: TableRef::unqualified("t"), + columns: vec![integer("id", true), integer("n", false)], + }); + edit(&tracker, 2); + let tabs = Rc::new(Tabs::default()); + tabs.0.borrow_mut().push(TabEntry { + id: tab, + title: "t".to_owned(), + open_transaction: false, + }); + let settled = Rc::new(RefCell::new(Vec::new())); + let events = Rc::new(RefCell::new(Vec::new())); + let settled_sink = settled.clone(); + let event_sink = events.clone(); + let hold = CountingHold::new(); + let handle = WorkspaceHandle::new(WorkspaceDeps { + session: active, + tasks: runtime.tasks(), + hold: Rc::new(hold.clone()), + history: None, + rows, + structures: Rc::new(StructureTrackerRegistry::default()), + directory: tabs.clone(), + events: Rc::new(move |event| { + if let WorkspaceEvent::SaveSettled(settled) = &event { + settled_sink.borrow_mut().push((**settled).clone()); + } + event_sink.borrow_mut().push(event); + }), + }); + Self { + handle, + tab, + tracker, + tabs, + settled, + events, + fake, + hold, + runtime: Some(runtime), + } + } + + /// An editor tab beside the table tab, with a transaction open. + pub(crate) fn open_transaction_tab(&self) -> Uuid { + let id = Uuid::new_v4(); + self.tabs.0.borrow_mut().push(TabEntry { + id, + title: "Query 1".to_owned(), + open_transaction: true, + }); + id + } + + pub(crate) fn save(&self) -> SaveSettled { + let started = self.handle.save(&[self.tab]); + assert!(matches!(started.as_slice(), [SaveStart::Started(_)]), "{started:?}"); + self.settle() + } + + pub(crate) fn settle(&self) -> SaveSettled { + wait_until(WAIT, || !self.settled.borrow().is_empty()).expect("the save to settle"); + self.settled.borrow_mut().remove(0) + } + + pub(crate) fn counters(&self) -> CallCounters { + self.fake.connection.counters() + } +} + +fn edit(tracker: &TabTrackerHandle, value: i64) { + let _ = tracker.apply(TrackerEdit::Cell { + row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + col: 1, + original: Value::Int(1), + value: Value::Int(value), + }); +} + +impl Drop for Workspace { + fn drop(&mut self) { + crate::test_support::drain_main_context(); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} diff --git a/linux/crates/app/src/workspace/unknown_cause.rs b/linux/crates/app/src/workspace/unknown_cause.rs new file mode 100644 index 0000000000..48d4ac5ebc --- /dev/null +++ b/linux/crates/app/src/workspace/unknown_cause.rs @@ -0,0 +1,39 @@ +use tablepro_core::CommitToken; + +/// Why nobody knows whether a save landed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UnknownCause { + /// The commit was sent and its answer never came. The token, where + /// the engine gives one, is what asks the server afterwards. + CommitAckLost { + token: Option, + }, + /// Some statements landed on an engine that cannot undo them. + PartiallyApplied, + /// The save was stopped, or timed out or lost its connection, before + /// the server confirmed it. + Interrupted, + Panicked, + /// The session closed under the save. + Closed, +} + +impl UnknownCause { + /// What happened to `tabs`, the tab names already joined. + pub fn sentence(&self, tabs: &str) -> String { + match self { + Self::Interrupted => crate::i18n::gettext_f( + "Saving {tabs} was stopped before the database confirmed it. The changes may already be in the database.", + &[("tabs", tabs)], + ), + Self::Panicked => crate::i18n::gettext_f( + "TablePro hit an internal error while saving {tabs}. The changes may already be in the database.", + &[("tabs", tabs)], + ), + Self::CommitAckLost { .. } | Self::PartiallyApplied | Self::Closed => crate::i18n::gettext_f( + "The connection was lost while saving {tabs}. The changes may already be in the database.", + &[("tabs", tabs)], + ), + } + } +} diff --git a/linux/crates/app/src/workspace/unknown_outcome_dialog.rs b/linux/crates/app/src/workspace/unknown_outcome_dialog.rs new file mode 100644 index 0000000000..cea8f7e542 --- /dev/null +++ b/linux/crates/app/src/workspace/unknown_outcome_dialog.rs @@ -0,0 +1,45 @@ +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; + +use super::dialog_response::respond; +use super::{DirtyKind, DirtyTab, UnknownCause}; + +const KEEP: &str = "cancel"; +const RELOAD: &str = "reload"; +const DISCARD: &str = "discard"; + +/// What the user chose for saves that may have landed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UnknownChoice { + KeepOpen, + /// Read the tables again to see what is there, and stay open. + Reload, + /// Drop the changes and go on closing. + Discard, +} + +pub(crate) async fn resolve_unknown_outcome(parent: >k::Widget, tabs: &[DirtyTab]) -> UnknownChoice { + let cause = tabs + .iter() + .find_map(|tab| match &tab.kind { + DirtyKind::OutcomeUnknown(cause) => Some(cause.clone()), + _ => None, + }) + .unwrap_or(UnknownCause::Interrupted); + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Save Result Unknown")), + Some(&cause.sentence(&DirtyTab::names(tabs))), + ); + dialog.add_response(KEEP, &crate::i18n::gettext("_Keep Open")); + dialog.add_response(RELOAD, &crate::i18n::gettext("_Reload Table")); + dialog.add_response(DISCARD, &crate::i18n::gettext("_Discard Changes")); + dialog.set_response_appearance(DISCARD, adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some(RELOAD)); + dialog.set_close_response(KEEP); + match respond(&dialog, parent).await.as_str() { + RELOAD => UnknownChoice::Reload, + DISCARD => UnknownChoice::Discard, + _ => UnknownChoice::KeepOpen, + } +} diff --git a/linux/crates/app/src/workspace/waiting_dialog.rs b/linux/crates/app/src/workspace/waiting_dialog.rs new file mode 100644 index 0000000000..bea038f283 --- /dev/null +++ b/linux/crates/app/src/workspace/waiting_dialog.rs @@ -0,0 +1,64 @@ +use std::pin::pin; +use std::time::Duration; + +use futures::future::{Either, select}; +use gtk4 as gtk; +use gtk4::glib; +use libadwaita as adw; +use libadwaita::prelude::*; + +use super::dialog_response::respond; +use super::{CloseScope, WorkspaceHandle}; + +const KEEP: &str = "keep"; +const STOP: &str = "stop"; +/// A save that lands within this needs no dialog to say so. +const QUIET: Duration = Duration::from_millis(250); + +/// Wait for the saves `scope` covers, with a dialog the user can stop +/// them from. True once none is running; false when the user chose to +/// keep the window open instead. +/// +/// A schema save has no deadline, so Stop Saving is the only way out of +/// a hung one besides Keep Open. +pub(crate) async fn wait_for_saves(parent: >k::Widget, handle: &WorkspaceHandle, scope: CloseScope) -> bool { + if let Either::Left(_) = select(pin!(handle.ledger().when_idle(scope)), glib::timeout_future(QUIET)).await { + return true; + } + let mut stopping = false; + loop { + if handle.ledger().in_flight_in(scope) == 0 { + return true; + } + let dialog = waiting_dialog(stopping); + let idle = handle.ledger().when_idle(scope); + match select(pin!(respond(&dialog, parent)), pin!(idle)).await { + Either::Right(((), _)) => { + dialog.force_close(); + return true; + } + Either::Left((response, _)) if response == STOP => { + handle.stop_saves(scope); + stopping = true; + } + Either::Left(_) => return false, + } + } +} + +/// The alert a response closes, so a stop already sent shows as a new +/// one that can only be kept open. +fn waiting_dialog(stopping: bool) -> adw::AlertDialog { + let body = match stopping { + true => crate::i18n::gettext("Stopping the save."), + false => crate::i18n::gettext("Wait for the save to finish, or stop it."), + }; + let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Saving Changes")), Some(&body)); + dialog.add_response(KEEP, &crate::i18n::gettext("_Keep Open")); + dialog.add_response(STOP, &crate::i18n::gettext("_Stop Saving")); + dialog.set_response_appearance(STOP, adw::ResponseAppearance::Destructive); + dialog.set_response_enabled(STOP, !stopping); + dialog.set_close_response(KEEP); + dialog.set_default_response(Some(KEEP)); + dialog +} diff --git a/linux/crates/app/src/workspace/window_inhibitor.rs b/linux/crates/app/src/workspace/window_inhibitor.rs new file mode 100644 index 0000000000..f4ccbd47f9 --- /dev/null +++ b/linux/crates/app/src/workspace/window_inhibitor.rs @@ -0,0 +1,216 @@ +use std::cell::RefCell; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; + +use super::InhibitReason; + +/// What holds the session back: the window's application, or a test's +/// counter. +pub trait InhibitTarget { + /// The cookie to release it with, or 0 when nothing was inhibited. + fn inhibit(&self, flags: gtk::ApplicationInhibitFlags, reason: &str) -> u32; + fn uninhibit(&self, cookie: u32); +} + +/// Holds logout, and suspend while a save runs, for as long as the +/// window has something a logout would lose. At most one inhibition is +/// held at a time, and dropping the inhibitor releases it. +pub struct WindowInhibitor { + target: Box, + reason: Option, + /// 0 when nothing is held, which is also what a session without an + /// inhibit service answers. + cookie: u32, +} + +impl WindowInhibitor { + pub fn new(target: impl InhibitTarget + 'static) -> Self { + Self { + target: Box::new(target), + reason: None, + cookie: 0, + } + } + + /// Hold the session back for `reason`, or let it go with `None`. The + /// old inhibition is released before a new one is taken. + pub fn update(&mut self, reason: Option) { + if reason == self.reason { + return; + } + self.release(); + self.reason = reason; + if let Some(reason) = reason { + self.cookie = self.target.inhibit(reason.flags(), &reason.text()); + } + } + + fn release(&mut self) { + if self.cookie != 0 { + self.target.uninhibit(std::mem::take(&mut self.cookie)); + } + } +} + +impl Drop for WindowInhibitor { + fn drop(&mut self) { + self.release(); + } +} + +impl std::fmt::Debug for WindowInhibitor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WindowInhibitor") + .field("reason", &self.reason) + .field("cookie", &self.cookie) + .finish_non_exhaustive() + } +} + +/// Inhibits through the window's application. The application is +/// looked up when it is needed, since a window is built before it is +/// added to one, and remembered so the release reaches the same one. +#[derive(Debug)] +pub struct ApplicationInhibit { + window: glib::WeakRef, + application: RefCell>, +} + +impl ApplicationInhibit { + pub fn new(window: &impl IsA) -> Self { + Self { + window: window.upcast_ref::().downgrade(), + application: RefCell::new(glib::WeakRef::new()), + } + } +} + +impl InhibitTarget for ApplicationInhibit { + fn inhibit(&self, flags: gtk::ApplicationInhibitFlags, reason: &str) -> u32 { + let Some(window) = self.window.upgrade() else { + return 0; + }; + let Some(application) = window.application() else { + return 0; + }; + self.application.replace(application.downgrade()); + application.inhibit(Some(&window), flags, Some(reason)) + } + + fn uninhibit(&self, cookie: u32) { + if let Some(application) = self.application.borrow().upgrade() { + application.uninhibit(cookie); + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use super::*; + + /// Hands out cookies and keeps the ones still held. + #[derive(Default, Clone)] + struct Session { + held: Rc>>, + next: Rc>, + refuse: bool, + } + + impl InhibitTarget for Session { + fn inhibit(&self, flags: gtk::ApplicationInhibitFlags, _reason: &str) -> u32 { + if self.refuse { + return 0; + } + let cookie = { + let mut next = self.next.borrow_mut(); + *next += 1; + *next + }; + self.held.borrow_mut().push((cookie, flags)); + cookie + } + + fn uninhibit(&self, cookie: u32) { + assert_ne!(cookie, 0, "released a cookie that was never held"); + self.held.borrow_mut().retain(|(held, _)| *held != cookie); + } + } + + #[test] + fn for_state_table() { + assert_eq!(InhibitReason::for_state(false, false), None); + assert_eq!( + InhibitReason::for_state(true, false), + Some(InhibitReason::UnsavedChanges) + ); + assert_eq!( + InhibitReason::for_state(false, true), + Some(InhibitReason::SavingChanges) + ); + assert_eq!(InhibitReason::for_state(true, true), Some(InhibitReason::SavingChanges)); + assert_eq!( + InhibitReason::UnsavedChanges.flags(), + gtk::ApplicationInhibitFlags::LOGOUT + ); + assert_eq!( + InhibitReason::SavingChanges.flags(), + gtk::ApplicationInhibitFlags::LOGOUT | gtk::ApplicationInhibitFlags::SUSPEND + ); + } + + #[gtk4::test] + fn update_keeps_single_cookie() { + let session = Session::default(); + let mut inhibitor = WindowInhibitor::new(session.clone()); + + inhibitor.update(Some(InhibitReason::UnsavedChanges)); + inhibitor.update(Some(InhibitReason::UnsavedChanges)); + assert_eq!(session.held.borrow().len(), 1); + assert_eq!(*session.next.borrow(), 1, "the same reason inhibited again"); + + inhibitor.update(Some(InhibitReason::SavingChanges)); + assert_eq!( + session.held.borrow().as_slice(), + [(2, InhibitReason::SavingChanges.flags())] + ); + + inhibitor.update(None); + assert!(session.held.borrow().is_empty()); + + inhibitor.update(Some(InhibitReason::UnsavedChanges)); + drop(inhibitor); + assert!( + session.held.borrow().is_empty(), + "dropping the inhibitor kept the session held" + ); + } + + #[gtk4::test] + fn a_refused_inhibit_is_never_released() { + let session = Session { + refuse: true, + ..Session::default() + }; + let mut inhibitor = WindowInhibitor::new(session.clone()); + + inhibitor.update(Some(InhibitReason::SavingChanges)); + inhibitor.update(None); + drop(inhibitor); + + assert!(session.held.borrow().is_empty()); + } + + #[gtk4::test] + fn a_window_without_an_application_inhibits_nothing() { + let window = gtk::Window::new(); + let target = ApplicationInhibit::new(&window); + + assert_eq!(target.inhibit(gtk::ApplicationInhibitFlags::LOGOUT, "unsaved"), 0); + target.uninhibit(3); + } +} diff --git a/linux/crates/app/src/workspace/workspace_event.rs b/linux/crates/app/src/workspace/workspace_event.rs new file mode 100644 index 0000000000..913b783f1c --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_event.rs @@ -0,0 +1,18 @@ +use uuid::Uuid; + +use super::SaveSettled; + +/// What the workspace did that its tabs have to show. +#[derive(Debug, Clone)] +pub enum WorkspaceEvent { + SaveStarted { + tab: Uuid, + }, + SaveSettled(Box), + /// The tabs' pending changes were dropped. `reload` asks each to read + /// its table again, for a save whose outcome nobody knew. + Discarded { + tabs: Vec, + reload: bool, + }, +} diff --git a/linux/crates/app/src/workspace/workspace_handle.rs b/linux/crates/app/src/workspace/workspace_handle.rs new file mode 100644 index 0000000000..ec6ca36ac0 --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_handle.rs @@ -0,0 +1,655 @@ +use std::collections::HashSet; +use std::rc::Rc; +use std::sync::Arc; + +use tablepro_core::save::{SavePlan, plan_save}; +use tablepro_core::{CommitResolution, DriverError, WriteKind, WriteReport}; +use tablepro_session::runtime::{ProcessHold, Tasks}; +use tablepro_session::{OperationError, OperationKind, Session}; +use tablepro_storage::query_history::Outcome; +use uuid::Uuid; + +use super::ran_statements::RanStatements; +use super::schema_write::run_schema_plan; +use super::{ + CloseScope, CloseStep, DirtyKind, DirtyTab, PendingState, RollbackCause, SaveId, SaveLedger, SaveOutcome, + SaveRejection, SaveSettled, SaveStart, SaveTicket, TabDirectory, UnknownCause, WorkspaceEvent, next_close_step, +}; +use crate::services::active_session::ActiveSession; +use crate::services::change_tracker::{StatementSource, TrackedChanges, pk_identity}; +use crate::services::change_tracker_registry::ChangeTrackerRegistry; +use crate::services::history_service::HistoryService; +use crate::services::structure_tracker_handle::StructureTrackerHandle; +use crate::services::structure_tracker_registry::StructureTrackerRegistry; +use crate::services::tab_tracker_handle::TabTrackerHandle; + +/// What a window's workspace saves and closes with. +pub struct WorkspaceDeps { + pub session: ActiveSession, + pub tasks: Tasks, + pub hold: Rc, + pub history: Option, + pub rows: Rc, + pub structures: Rc, + pub directory: Rc, + pub events: Rc, +} + +/// Starts, stops and settles a window's saves, and says what closing +/// any part of it would lose. Clones share one workspace. +#[derive(Clone)] +pub struct WorkspaceHandle(Rc); + +struct Inner { + deps: WorkspaceDeps, + ledger: SaveLedger, +} + +impl Inner { + /// The tracker has taken the outcome in, so the ledger lets the + /// save go, which may wake a close waiting on it, and the tab is + /// told. + fn settled(&self, id: SaveId, settled: SaveSettled) { + self.ledger.settle(id); + (self.deps.events)(WorkspaceEvent::SaveSettled(Box::new(settled))); + } +} + +/// What a save's work brings back to the GTK thread. +struct SaveWork { + written: Result, + /// The server's answer about a commit whose acknowledgement was lost. + resolution: Option>, + plan: Option, +} + +impl SaveWork { + fn failed(error: OperationError) -> Self { + Self { + written: Err(error), + resolution: None, + plan: None, + } + } +} + +impl WorkspaceHandle { + pub fn new(deps: WorkspaceDeps) -> Self { + Self(Rc::new(Inner { + deps, + ledger: SaveLedger::new(), + })) + } + + pub fn ledger(&self) -> &SaveLedger { + &self.0.ledger + } + + /// Save each tab. A tab with nothing pending, or that is not a table + /// or structure tab, has nothing to save. + pub fn save(&self, tabs: &[Uuid]) -> Vec { + tabs.iter().map(|tab| self.save_tab(*tab)).collect() + } + + /// Stop the saves `scope` covers. Each settles when its driver + /// answers the cancel, as rolled back or with an unknown outcome. + pub fn stop_saves(&self, scope: CloseScope) { + self.0.ledger.cancel(scope); + } + + /// Drop the tabs' pending changes, and with them a save whose + /// outcome was unknown. `reload` has each tab read its table again. + pub fn discard(&self, tabs: &[Uuid], reload: bool) { + for tab in tabs { + if let Some(tracker) = self.0.deps.rows.get(*tab) { + let _ = tracker.discard(); + } + if let Some(tracker) = self.0.deps.structures.get(*tab) { + tracker.clear(); + } + } + self.emit(WorkspaceEvent::Discarded { + tabs: tabs.to_vec(), + reload, + }); + } + + /// Every tab closing would lose something in, in tab order. + pub fn dirty_tabs(&self) -> Vec { + self.0 + .deps + .directory + .tabs() + .into_iter() + .filter_map(|entry| { + let kind = match self.pending_state(entry.id) { + PendingState::OutcomeUnknown(cause) => DirtyKind::OutcomeUnknown(cause), + PendingState::Unsaved | PendingState::Saving => DirtyKind::Unsaved, + PendingState::Clean if entry.open_transaction => DirtyKind::OpenTransaction, + PendingState::Clean => return None, + }; + Some(DirtyTab { + id: entry.id, + title: entry.title, + kind, + }) + }) + .collect() + } + + /// What closing `scope` has to settle next. Open transactions the + /// user already agreed to lose in this close are not asked again. + pub fn close_step(&self, scope: CloseScope, acknowledged: &HashSet) -> CloseStep { + let dirty: Vec = self + .dirty_tabs() + .into_iter() + .filter(|tab| !(tab.kind == DirtyKind::OpenTransaction && acknowledged.contains(&tab.id))) + .collect(); + next_close_step(scope, self.0.ledger.in_flight_in(scope), &dirty) + } + + fn pending_state(&self, tab: Uuid) -> PendingState { + if let Some(tracker) = self.0.deps.rows.get(tab) { + return tracker.pending_state(); + } + self.0 + .deps + .structures + .get(tab) + .map_or(PendingState::Clean, |tracker| tracker.pending_state()) + } + + fn emit(&self, event: WorkspaceEvent) { + (self.0.deps.events)(event); + } + + fn save_tab(&self, tab: Uuid) -> SaveStart { + if let Some(tracker) = self.0.deps.rows.get(tab) { + return self.save_rows(tab, &tracker); + } + if let Some(tracker) = self.0.deps.structures.get(tab) { + return self.save_structure(tab, &tracker); + } + SaveStart::NothingToSave + } + + /// The session to save through, refused before anything is taken + /// from the tracker when it cannot write. + fn writable_session(&self, state: PendingState) -> Result>, SaveRejection> { + match state { + PendingState::Clean => return Ok(None), + PendingState::OutcomeUnknown(_) => return Err(SaveRejection::OutcomeUnknown), + PendingState::Saving => return Err(SaveRejection::AlreadySaving), + PendingState::Unsaved => {} + } + let session = self.0.deps.session.get().ok_or(SaveRejection::NoConnection)?; + if session.read_only() { + return Err(SaveRejection::ReadOnly); + } + Ok(Some(session)) + } + + /// Take what `tab` saves from its tracker, with a ledger entry held + /// for as long as the save runs. + fn begin( + &self, + tab: Uuid, + kind: WriteKind, + state: PendingState, + take: impl FnOnce() -> Result, SaveRejection>, + ) -> Result<(Arc, SaveTicket, T), SaveStart> { + let session = match self.writable_session(state) { + Ok(Some(session)) => session, + Ok(None) => return Err(SaveStart::NothingToSave), + Err(rejection) => return Err(SaveStart::Rejected(rejection)), + }; + let Some(ticket) = self.0.ledger.begin(&*self.0.deps.hold, tab, kind) else { + return Err(SaveStart::Rejected(SaveRejection::AlreadySaving)); + }; + let taken = match take() { + Ok(Some(taken)) => taken, + Ok(None) => { + self.0.ledger.settle(ticket.id); + return Err(SaveStart::NothingToSave); + } + Err(rejection) => { + self.0.ledger.settle(ticket.id); + return Err(SaveStart::Rejected(rejection)); + } + }; + self.emit(WorkspaceEvent::SaveStarted { tab }); + Ok((session, ticket, taken)) + } + + fn save_rows(&self, tab: Uuid, tracker: &TabTrackerHandle) -> SaveStart { + let (session, ticket, batch) = match self.begin(tab, WriteKind::RowChanges, tracker.pending_state(), || { + tracker.begin_save() + }) { + Ok(begun) => begun, + Err(start) => return start, + }; + let history = self.history_entry(&session); + let cancel = ticket.cancel.clone(); + let (table, columns, change_set) = (batch.table, batch.columns, batch.changes.changes.clone()); + let work = async move { + let planned = session + .run_cancellable( + OperationKind::Save(WriteKind::RowChanges), + &cancel, + move |lease, options| { + let (table, columns, changes) = (table.clone(), columns.clone(), change_set.clone()); + async move { + let plan = + plan_save(&*lease.dialect(), &table, &columns, &pk_identity(&columns), &changes)?; + let written = lease.write_batch(&plan.batch, options).await; + Ok((plan, written)) + } + }, + ) + .await; + let (plan, written) = match planned { + Ok((plan, written)) => (Some(plan), written.map_err(OperationError::from)), + Err(error) => (None, Err(error)), + }; + let resolution = resolve_commit(&session, &written).await; + let statements: Vec = plan + .iter() + .flat_map(|plan| plan.batch.steps.iter().map(|step| step.statement.sql().to_owned())) + .collect(); + record(history, statements, &written, resolution.as_ref()).await; + SaveWork { + written, + resolution, + plan, + } + }; + + let inner = self.0.clone(); + let tracker = tracker.clone(); + let (changes, snapshot, id) = (batch.changes, batch.snapshot, ticket.id); + let _delivery = crate::runtime::spawn_and_deliver(&self.0.deps.tasks, work, move |finished| { + let work = finished.unwrap_or_else(|failure| SaveWork::failed(OperationError::from(failure))); + let failing = failing_step(&work, &changes); + let outcome = outcome_of(work.written, work.resolution); + let _settled = tracker.settle_save(&outcome, snapshot); + inner.settled( + id, + SaveSettled { + tab, + kind: WriteKind::RowChanges, + outcome, + failing, + saved_table_name: None, + }, + ); + }); + SaveStart::Started(id) + } + + fn save_structure(&self, tab: Uuid, tracker: &StructureTrackerHandle) -> SaveStart { + let (session, ticket, pending) = + match self.begin(tab, WriteKind::SchemaChanges, tracker.pending_state(), || { + tracker.begin_save() + }) { + Ok(begun) => begun, + Err(start) => return start, + }; + let history = self.history_entry(&session); + let cancel = ticket.cancel.clone(); + let saved_table_name = pending.saved_table_name(); + let (table, operation) = (pending.table(), pending.operation()); + let work = async move { + let planned = run_schema_plan(session.clone(), table, operation, &cancel, move |ddl, _| { + pending.plan(ddl) + }) + .await; + let (statements, written) = planned.unwrap_or_else(|error| (Vec::new(), Err(error))); + let resolution = resolve_commit(&session, &written).await; + record(history, statements, &written, resolution.as_ref()).await; + SaveWork { + written, + resolution, + plan: None, + } + }; + + let inner = self.0.clone(); + let tracker = tracker.clone(); + let id = ticket.id; + let _delivery = crate::runtime::spawn_and_deliver(&self.0.deps.tasks, work, move |finished| { + let work = finished.unwrap_or_else(|failure| SaveWork::failed(OperationError::from(failure))); + let outcome = outcome_of(work.written, work.resolution); + tracker.settle_save(&outcome); + inner.settled( + id, + SaveSettled { + tab, + kind: WriteKind::SchemaChanges, + outcome, + failing: None, + saved_table_name, + }, + ); + }); + SaveStart::Started(id) + } + + /// Captured before the work starts: the connection may be gone by + /// the time the answer comes back. + fn history_entry(&self, session: &Session) -> HistoryEntry { + HistoryEntry { + history: self.0.deps.history.as_ref().and_then(HistoryService::store), + connection: Some(session.metadata().clone()), + } + } +} + +impl std::fmt::Debug for WorkspaceHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WorkspaceHandle") + .field("ledger", &self.0.ledger) + .finish_non_exhaustive() + } +} + +struct HistoryEntry { + history: Option, + connection: Option, +} + +/// A save changes the database as surely as a statement typed into the +/// editor, so it goes in the same history. A history that cannot be +/// written is worth a log line, never a lost save. +async fn record( + entry: HistoryEntry, + statements: Vec, + written: &Result, + resolution: Option<&Result>, +) { + let ran = RanStatements::starting(entry.history, entry.connection, statements); + let (rows, outcome) = match (written, resolution) { + (Ok(report), _) => ( + report.total_rows().and_then(|rows| i64::try_from(rows).ok()), + Outcome::Success, + ), + (Err(_), Some(Ok(CommitResolution::Applied))) => (None, Outcome::Success), + (Err(error), _) => ( + None, + Outcome::Error(crate::ui::error_text::operation_error_message(error)), + ), + }; + ran.finished(rows, outcome).await; +} + +/// Ask the server what became of a commit whose acknowledgement was +/// lost, when the engine left a way to ask. +async fn resolve_commit( + session: &Session, + written: &Result, +) -> Option> { + let Err(OperationError::Driver(DriverError::CommitOutcomeUnknown { token: Some(token), .. })) = written else { + return None; + }; + let token = token.clone(); + let resolved = session + .run(OperationKind::Metadata, move |lease, options| { + let token = token.clone(); + async move { + lease + .resolve_commit(&token, options) + .await + .map_err(OperationError::from) + } + }) + .await; + if let Err(error) = &resolved { + tracing::warn!(%error, "could not ask the server whether a save landed"); + } + Some(resolved) +} + +/// The outcome once the server has answered for a lost acknowledgement. +/// An answer that is not final, or no answer, leaves it unknown. +fn outcome_of( + written: Result, + resolution: Option>, +) -> SaveOutcome { + let outcome = SaveOutcome::classify(written); + let SaveOutcome::Unknown(UnknownCause::CommitAckLost { .. }) = &outcome else { + return outcome; + }; + match resolution { + Some(Ok(CommitResolution::Applied)) => SaveOutcome::Committed { rows: None }, + Some(Ok(CommitResolution::NotApplied)) => SaveOutcome::RolledBack(RollbackCause::NotApplied), + Some(Ok(CommitResolution::PartiallyApplied)) => SaveOutcome::Unknown(UnknownCause::PartiallyApplied), + _ => outcome, + } +} + +/// The grid row behind the statement a row save stopped at, when the +/// engine said which one it was. +fn failing_step(work: &SaveWork, changes: &TrackedChanges) -> Option { + let (Some(plan), Err(OperationError::Driver(error))) = (&work.plan, &work.written) else { + return None; + }; + failing_source(plan, changes, error) +} + +fn failing_source(plan: &SavePlan, changes: &TrackedChanges, error: &DriverError) -> Option { + let step = match error { + DriverError::RolledBack { statement_index, .. } + | DriverError::RowGuardFailed { statement_index, .. } + | DriverError::GuardUnsupported { statement_index } + | DriverError::PartiallyApplied { statement_index, .. } => *statement_index, + _ => return None, + }; + changes.source(plan.origin(step)?) +} + +#[cfg(test)] +mod tests { + use tablepro_core::meta::TableRef; + use tablepro_core::{CommitToken, LossPhase, Value}; + use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; + + use super::*; + use crate::services::change_tracker::RowKey; + use crate::services::tracker_edit::TrackerEdit; + use crate::test_support::wait_until; + use crate::workspace::test_workspace::{WAIT, Workspace, integer, sqlite}; + + fn commit_ack_lost(resolution: Result) -> ConnectionScript { + let mut script = ConnectionScript::default(); + script + .write_batch + .always(FakeReply::err(DriverError::CommitOutcomeUnknown { + token: Some(CommitToken::PostgresXid { + xid: 812, + legacy_txid: false, + }), + source: Box::new(DriverError::ConnectionLost { + during: LossPhase::Commit, + }), + })); + script.resolve_commit.always(FakeReply::Ready(resolution)); + script + } + + #[gtk4::test] + fn commit_ack_lost_applied_reloads() { + let workspace = Workspace::open(commit_ack_lost(Ok(CommitResolution::Applied))); + + let settled = workspace.save(); + + assert_eq!(settled.outcome, SaveOutcome::Committed { rows: None }); + assert_eq!(CallCounters::get(&workspace.counters().resolve_commit), 1); + assert_eq!(workspace.tracker.pending_state(), PendingState::Clean); + assert_eq!(workspace.handle.ledger().in_flight(), 0); + assert_eq!(workspace.hold.live(), 0, "the settled save still held the process"); + } + + #[gtk4::test] + fn not_applied_rolls_back() { + let workspace = Workspace::open(commit_ack_lost(Ok(CommitResolution::NotApplied))); + + let settled = workspace.save(); + + assert_eq!(settled.outcome, SaveOutcome::RolledBack(RollbackCause::NotApplied)); + assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); + assert!(matches!( + workspace.handle.save(&[workspace.tab]).as_slice(), + [SaveStart::Started(_)] + )); + workspace.settle(); + } + + #[gtk4::test] + fn unresolvable_freezes() { + for resolution in [ + Ok(CommitResolution::Unresolvable), + Ok(CommitResolution::StillRunning), + Err(DriverError::ConnectionLost { + during: LossPhase::Statement, + }), + ] { + let workspace = Workspace::open(commit_ack_lost(resolution.clone())); + + let settled = workspace.save(); + + assert!( + matches!( + settled.outcome, + SaveOutcome::Unknown(UnknownCause::CommitAckLost { token: Some(_) }) + ), + "{resolution:?}: {:?}", + settled.outcome + ); + assert!(matches!( + workspace.tracker.pending_state(), + PendingState::OutcomeUnknown(_) + )); + assert_eq!( + workspace.handle.save(&[workspace.tab]), + [SaveStart::Rejected(SaveRejection::OutcomeUnknown)] + ); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); + assert!(matches!( + workspace.handle.dirty_tabs().as_slice(), + [DirtyTab { + kind: DirtyKind::OutcomeUnknown(_), + .. + }] + )); + } + } + + #[gtk4::test] + fn stop_saves_cancels_write_batch_token() { + let mut script = ConnectionScript::default(); + script + .write_batch + .always(FakeReply::StoppedWith(Err(DriverError::RolledBack { + statement_index: 0, + source: Box::new(DriverError::Cancelled), + }))); + let workspace = Workspace::open(script); + let started = workspace.handle.save(&[workspace.tab]); + assert!(matches!(started.as_slice(), [SaveStart::Started(_)])); + wait_until(WAIT, || { + !workspace.fake.connection.observed_cancels("write_batch").is_empty() + }) + .expect("the write to start"); + let observed = workspace.fake.connection.observed_cancels("write_batch"); + assert!(!observed[0].is_cancelled()); + assert_eq!(workspace.hold.live(), 1); + + workspace.handle.stop_saves(CloseScope::Tab(workspace.tab)); + let settled = workspace.settle(); + + assert!(observed[0].is_cancelled(), "the write never saw the stop"); + assert!(matches!(settled.outcome, SaveOutcome::RolledBack(_))); + assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); + assert_eq!(workspace.handle.ledger().in_flight(), 0); + } + + #[gtk4::test] + fn a_read_only_connection_refuses_before_taking_the_changes() { + let workspace = Workspace::open(ConnectionScript { + read_only: true, + ..ConnectionScript::default() + }); + + assert_eq!( + workspace.handle.save(&[workspace.tab]), + [SaveStart::Rejected(SaveRejection::ReadOnly)] + ); + assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); + assert_eq!(workspace.handle.ledger().in_flight(), 0); + } + + fn planned() -> (SavePlan, TrackedChanges, RowKey) { + let columns = vec![integer("id", true), integer("n", false)]; + let edited = RowKey::from_pk_values(&[Value::Int(3)]).unwrap(); + let tracker = TabTrackerHandle::default(); + let _ = tracker.apply(TrackerEdit::Insert { + values: vec![Value::Int(10), Value::Int(1)], + }); + let _ = tracker.apply(TrackerEdit::Cell { + row_key: edited.clone(), + col: 1, + original: Value::Int(1), + value: Value::Int(2), + }); + let changes = tracker.change_set(&columns).unwrap(); + let plan = plan_save( + &*sqlite(), + &TableRef::unqualified("t"), + &columns, + &pk_identity(&columns), + &changes.changes, + ) + .unwrap(); + (plan, changes, edited) + } + + #[test] + fn a_guard_failure_flashes_the_row_it_was_about() { + let (plan, changes, edited) = planned(); + let error = DriverError::RowGuardFailed { + statement_index: 1, + expected: 1, + actual: 0, + }; + + let source = failing_source(&plan, &changes, &error); + + assert!(matches!(source, Some(StatementSource::Update { row_key }) if row_key == edited)); + } + + #[test] + fn a_rolled_back_insert_flashes_its_draft() { + let (plan, changes, _) = planned(); + let error = DriverError::RolledBack { + statement_index: 0, + source: Box::new(DriverError::server("duplicate key")), + }; + + assert!(matches!( + failing_source(&plan, &changes, &error), + Some(StatementSource::Insert { .. }) + )); + } + + #[test] + fn an_error_that_names_no_statement_flashes_nothing() { + let (plan, changes, _) = planned(); + + assert!(failing_source(&plan, &changes, &DriverError::Busy).is_none()); + let past_the_end = DriverError::RowGuardFailed { + statement_index: 7, + expected: 1, + actual: 0, + }; + assert!(failing_source(&plan, &changes, &past_the_end).is_none()); + } +} diff --git a/linux/data/resources/ui/browse-tab.ui b/linux/data/resources/ui/browse-tab.ui index f33a3d1ae7..523a0c37d0 100644 --- a/linux/data/resources/ui/browse-tab.ui +++ b/linux/data/resources/ui/browse-tab.ui @@ -202,6 +202,12 @@ + + + false + Saving… + + Save @@ -217,6 +223,16 @@ Discard all pending edits + + + _Stop Saving + true + false + + + diff --git a/linux/data/resources/ui/structure-tab.ui b/linux/data/resources/ui/structure-tab.ui index 53bdc08616..70485e2cc8 100644 --- a/linux/data/resources/ui/structure-tab.ui +++ b/linux/data/resources/ui/structure-tab.ui @@ -219,6 +219,12 @@ + + + false + Saving… + + @@ -244,6 +250,16 @@ false + + + _Stop Saving + true + false + + + diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 69bcb056df..5b4a49670d 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -27,6 +27,7 @@ crates/app/src/services/history_availability.rs crates/app/src/services/history_service.rs crates/app/src/services/mod.rs crates/app/src/services/pending_structure.rs +crates/app/src/services/save_batch.rs crates/app/src/services/secret_labels.rs crates/app/src/services/secret_save_report.rs crates/app/src/services/session_context.rs @@ -51,16 +52,17 @@ crates/app/src/test_support/tests.rs crates/app/src/test_support/unlabelled_widget.rs crates/app/src/test_support/wait_timed_out.rs crates/app/src/test_support/widget_lookup.rs +crates/app/src/ui/app/close_flow.rs crates/app/src/ui/app/connect_flow.rs crates/app/src/ui/app/connection.rs crates/app/src/ui/app/import.rs crates/app/src/ui/app/mod.rs -crates/app/src/ui/app/ran_statements.rs crates/app/src/ui/app/row_ops.rs +crates/app/src/ui/app/saves.rs crates/app/src/ui/app/sidebar_section_header.rs crates/app/src/ui/app/status_pages.rs crates/app/src/ui/app/structure.rs -crates/app/src/ui/app/teardown_guard.rs +crates/app/src/ui/app/window_tabs.rs crates/app/src/ui/app/workspace_persistence.rs crates/app/src/ui/app/workspace_tabs.rs crates/app/src/ui/browse_tab/browse_command.rs @@ -172,13 +174,44 @@ crates/app/src/ui/style_scheme_grid.rs crates/app/src/ui/style_scheme_resolver.rs crates/app/src/ui/welcome_view.rs crates/app/src/ui/window_geometry.rs +crates/app/src/workspace/close_decision.rs +crates/app/src/workspace/close_guard.rs +crates/app/src/workspace/close_scope.rs +crates/app/src/workspace/close_step.rs +crates/app/src/workspace/dialog_response.rs +crates/app/src/workspace/dirty_kind.rs +crates/app/src/workspace/dirty_tab.rs crates/app/src/workspace/draft_writer.rs +crates/app/src/workspace/inhibit_reason.rs crates/app/src/workspace/mod.rs +crates/app/src/workspace/open_transaction_dialog.rs +crates/app/src/workspace/pending_state.rs crates/app/src/workspace/persist_debouncer.rs +crates/app/src/workspace/ran_statements.rs crates/app/src/workspace/restored_drafts.rs +crates/app/src/workspace/rollback_cause.rs +crates/app/src/workspace/save_changes_dialog.rs +crates/app/src/workspace/save_id.rs +crates/app/src/workspace/save_ledger.rs +crates/app/src/workspace/save_outcome.rs +crates/app/src/workspace/save_rejection.rs +crates/app/src/workspace/save_settled.rs +crates/app/src/workspace/save_snapshot.rs +crates/app/src/workspace/save_start.rs +crates/app/src/workspace/save_ticket.rs +crates/app/src/workspace/schema_write.rs crates/app/src/workspace/sort_record.rs +crates/app/src/workspace/tab_directory.rs +crates/app/src/workspace/tab_entry.rs crates/app/src/workspace/tab_record.rs +crates/app/src/workspace/test_workspace.rs +crates/app/src/workspace/unknown_cause.rs +crates/app/src/workspace/unknown_outcome_dialog.rs +crates/app/src/workspace/waiting_dialog.rs +crates/app/src/workspace/window_inhibitor.rs +crates/app/src/workspace/workspace_event.rs crates/app/src/workspace/workspace_file_name.rs +crates/app/src/workspace/workspace_handle.rs crates/app/src/workspace/workspace_load.rs crates/app/src/workspace/workspace_load_error.rs crates/app/src/workspace/workspace_record.rs From afb05f13993125e1bd5d5bd251aa8cd2a9e8b017 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:53:53 +0700 Subject: [PATCH 134/178] test(linux): cover closing every connection when the window closes --- linux/crates/app/src/ui/app/close_flow.rs | 133 +++++++++++++++++++--- 1 file changed, 119 insertions(+), 14 deletions(-) diff --git a/linux/crates/app/src/ui/app/close_flow.rs b/linux/crates/app/src/ui/app/close_flow.rs index a0da46bf29..251db4de68 100644 --- a/linux/crates/app/src/ui/app/close_flow.rs +++ b/linux/crates/app/src/ui/app/close_flow.rs @@ -9,6 +9,9 @@ use uuid::Uuid; use super::workspace_persistence::{WorkspacePersistence, WorkspaceTabs}; use super::{App, AppMsg, WorkspaceTab, connection}; +use tablepro_session::runtime::ProcessHold; + +use crate::services::active_session::ActiveSession; use crate::services::session_context::SessionContext; use crate::workspace::{CloseDecision, CloseGuard, CloseScope, CloseStep, InhibitReason, WorkspaceHandle}; @@ -72,12 +75,6 @@ impl WindowShutdown { /// Runs once, from the close request, or from the component's /// shutdown for a window destroyed without one. `window` is the one /// still on screen, whose size is kept. - /// - /// The tabs go first: each editor tab closes its own connection, - /// which outlives the window's, and every read on the window's - /// session stops. Only then does the session itself close. Every - /// close holds the process until it ends, so quitting does not cut - /// a connection off mid-close. pub(super) fn run(&self, window: Option<&adw::ApplicationWindow>) { if self.done.replace(true) { return; @@ -87,14 +84,12 @@ impl WindowShutdown { crate::ui::window_geometry::persist(window, &parts.settings); } self.flush(); - for tab in parts.tabs.borrow().values() { - match tab { - WorkspaceTab::Editor(slot) => slot.editor.shutdown(), - WorkspaceTab::Structure(slot) => slot.structure.shutdown(), - WorkspaceTab::Table(slot) => slot.browse.shutdown(), - } - } - connection::close_window_session(&parts.context.active, &*parts.context.hold, &parts.connect_attempts); + close_connections( + &parts.tabs, + &parts.context.active, + &*parts.context.hold, + &parts.connect_attempts, + ); } /// The last column drag, filter change, editor text and tab list may @@ -122,6 +117,27 @@ impl WindowShutdown { } } +/// The tabs go first: each editor tab closes its own connection, which +/// outlives the window's, and every read on the window's session stops. +/// Only then does the session itself close. Every close holds the +/// process until it ends, so quitting does not cut a connection off +/// mid-close. +fn close_connections( + tabs: &WorkspaceTabs, + active: &ActiveSession, + hold: &dyn ProcessHold, + attempts: &tablepro_session::ConnectAttempts, +) { + for tab in tabs.borrow().values() { + match tab { + WorkspaceTab::Editor(slot) => slot.editor.shutdown(), + WorkspaceTab::Structure(slot) => slot.structure.shutdown(), + WorkspaceTab::Table(slot) => slot.browse.shutdown(), + } + } + connection::close_window_session(active, hold, attempts); +} + /// The window closes only once its prompts approve. A close request /// while they are deciding is dropped, since their dialog is already /// on screen. @@ -227,3 +243,92 @@ impl App { self.inhibitor.update(InhibitReason::for_state(dirty, saving)); } } + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::HashMap; + use std::sync::Arc; + use std::time::Duration; + + use tablepro_core::{EditorSession, TransactionState}; + use tablepro_session::ConnectAttempts; + use tablepro_session::runtime::CountingHold; + use tablepro_storage::DraftId; + use tablepro_test_support::{ConnectionScript, FakeEditorSession, FakeReply, FakeRun, Replies}; + + use super::*; + use crate::test_support::{FakeSession, MemorySettings, wait_until}; + use crate::ui::app::window_tabs::WindowTabs; + use crate::ui::app::{EditorTabSlot, write_workspace_tab_id}; + use crate::ui::editor::{SqlEditor, SqlEditorInit}; + use crate::workspace::{TabDirectory, TabEntry}; + + const WAIT: Duration = Duration::from_secs(10); + + #[gtk4::test] + fn closing_the_window_names_open_transactions_and_closes_every_connection() { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new()).leaving(TransactionState::InTransaction)); + let editor_session = Arc::new(FakeEditorSession::new(runs)); + let mut script = ConnectionScript::default(); + let opened: Arc = editor_session.clone(); + script.open_editor_session.always(FakeReply::ok(opened)); + script.probe.always(FakeReply::ok(())); + let fake = FakeSession::start(script, [], runtime.tasks()); + let active = ActiveSession::new(); + active.replace(fake.session.clone()); + let hold = Rc::new(CountingHold::new()); + let editor = SqlEditor::new(SqlEditorInit { + initial_query: Some("BEGIN; UPDATE t SET a = 1".to_owned()), + settings: settings.get().clone(), + history: None, + tasks: runtime.tasks(), + session: active.clone(), + hold: hold.clone(), + close_timeout: Duration::from_secs(5), + }); + editor.run(); + wait_until(WAIT, || !editor.is_running()).expect("the run to end"); + let view = adw::TabView::new(); + let page = view.append(&editor); + page.set_title("• Query 1"); + let id = Uuid::new_v4(); + write_workspace_tab_id(&page, id); + let tabs: WorkspaceTabs = Rc::new(RefCell::new(HashMap::from([( + id, + WorkspaceTab::Editor(EditorTabSlot { + editor, + page, + draft: DraftId::new(), + }), + )]))); + let directory = WindowTabs::new(tabs.clone()); + directory.set_view(Some(&view)); + + assert_eq!( + directory.tabs(), + [TabEntry { + id, + title: "Query 1".to_owned(), + open_transaction: true, + }] + ); + + close_connections(&tabs, &active, &*hold, &ConnectAttempts::new()); + + assert!(!active.is_connected()); + wait_until(WAIT, || { + editor_session.close_count() == 1 && fake.connection.is_closed() && hold.live() == 0 + }) + .expect("both connections to close under the hold"); + assert!(fake.session.is_shut_down()); + drop(tabs); + crate::test_support::drain_main_context(); + runtime.shutdown(); + } +} From 28a0ae71a5669a02f94cb59723784e9f44379375 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:56:22 +0700 Subject: [PATCH 135/178] fix(linux): keep a SQL Server column's data mask through ALTER COLUMN --- .../crates/core/src/ddl/column_attributes.rs | 4 ++++ linux/crates/drivers/mssql/src/catalog/mod.rs | 21 +++++++++++++++++++ .../mssql/src/catalog/mssql_column_row.rs | 12 ++++++++--- linux/crates/drivers/mssql/src/ddl.rs | 20 ++++++++++++++---- .../drivers/mssql/tests/integration/ddl.rs | 14 +++++++++++-- 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/linux/crates/core/src/ddl/column_attributes.rs b/linux/crates/core/src/ddl/column_attributes.rs index 9230c2bcf5..55f8e67dad 100644 --- a/linux/crates/core/src/ddl/column_attributes.rs +++ b/linux/crates/core/src/ddl/column_attributes.rs @@ -24,6 +24,10 @@ pub struct ColumnAttributes { pub spatial_reference: Option, /// SQL Server `SPARSE`. pub sparse: bool, + /// The function a SQL Server dynamic data mask applies, as in + /// `email()`. ALTER COLUMN drops the mask, which shows the data to + /// every user without UNMASK. + pub masking_function: Option, /// A CHECK constraint MariaDB keeps on the column itself. MODIFY /// drops it unless the definition restates it; every other engine /// keeps a CHECK on the table, where a redefinition leaves it alone. diff --git a/linux/crates/drivers/mssql/src/catalog/mod.rs b/linux/crates/drivers/mssql/src/catalog/mod.rs index ac5f77f3b4..902edaa79e 100644 --- a/linux/crates/drivers/mssql/src/catalog/mod.rs +++ b/linux/crates/drivers/mssql/src/catalog/mod.rs @@ -59,6 +59,12 @@ pub(crate) fn column_definition(row: &MssqlColumnRow) -> ColumnDefinition { collation: row.collation.as_deref().map(|name| CollationName::new(None, name)), generated, sparse: row.sparse, + // The catalogue prints a quote in a padding string doubled, as it + // would sit inside a literal, so `"X's"` reads back as `"X''s"`. + masking_function: row + .masking_function + .as_deref() + .map(|function| function.replace("''", "'")), identity: match row.identity { true => IdentityStyle::MssqlIdentity, false => IdentityStyle::None, @@ -233,6 +239,21 @@ mod tests { assert_eq!(classify_default(None, &text), ColumnDefault::None); } + #[test] + fn a_mask_reads_back_as_its_function_was_written() { + let mut values = vec![Value::Null; 23]; + values[0] = Value::Text("email".to_owned()); + values[1] = Value::Text("varchar".to_owned()); + values[22] = Value::Text(r#"partial(1, "X''s", 2)"#.to_owned()); + + let row = MssqlColumnRow::from_values(&values).expect("a row"); + + assert_eq!( + column_definition(&row).attributes.masking_function.as_deref(), + Some(r#"partial(1, "X's", 2)"#) + ); + } + #[test] fn an_unbalanced_definition_stays_the_servers_expression() { assert!(matches!( diff --git a/linux/crates/drivers/mssql/src/catalog/mssql_column_row.rs b/linux/crates/drivers/mssql/src/catalog/mssql_column_row.rs index 2f5814a79c..329433a520 100644 --- a/linux/crates/drivers/mssql/src/catalog/mssql_column_row.rs +++ b/linux/crates/drivers/mssql/src/catalog/mssql_column_row.rs @@ -9,8 +9,9 @@ use super::sql_server_type::{CatalogFacts, SqlServerType}; /// An alias type is classified by the system type it is stored as, /// which `TYPE_NAME(system_type_id)` names; a CLR type keeps its own /// name. A collation is reported only where it differs from the -/// database's, which is the default a column takes, and the last column -/// says whether its code page differs too. +/// database's, which is the default a column takes, and the column after +/// it says whether its code page differs too. The last is the function +/// of the column's data mask, if it has one. pub(crate) const COLUMNS_SQL: &str = "SELECT \ c.name, \ t.name, \ @@ -38,11 +39,14 @@ pub(crate) const COLUMNS_SQL: &str = "SELECT \ CAST(ISNULL(cc.is_persisted, 0) AS int), \ CAST(CASE WHEN COLLATIONPROPERTY(c.collation_name, 'CodePage') \ <> COLLATIONPROPERTY(CAST(DATABASEPROPERTYEX(DB_NAME(), 'Collation') AS nvarchar(128)), 'CodePage') \ - THEN 1 ELSE 0 END AS int) \ + THEN 1 ELSE 0 END AS int), \ + mc.masking_function \ FROM sys.columns c \ JOIN sys.types t ON t.user_type_id = c.user_type_id \ LEFT JOIN sys.default_constraints dc ON dc.object_id = c.default_object_id \ LEFT JOIN sys.computed_columns cc ON cc.object_id = c.object_id AND cc.column_id = c.column_id \ + LEFT JOIN sys.masked_columns mc ON mc.object_id = c.object_id AND mc.column_id = c.column_id \ + AND mc.is_masked = 1 \ LEFT JOIN ( \ SELECT ic.object_id, ic.column_id \ FROM sys.index_columns ic \ @@ -71,6 +75,7 @@ pub(crate) struct MssqlColumnRow { pub(crate) sparse: bool, pub(crate) computed_definition: Option, pub(crate) persisted: bool, + pub(crate) masking_function: Option, } impl MssqlColumnRow { @@ -105,6 +110,7 @@ impl MssqlColumnRow { sparse: cells.flag(18), computed_definition: cells.text(19), persisted: cells.flag(20), + masking_function: cells.text(22), }) } } diff --git a/linux/crates/drivers/mssql/src/ddl.rs b/linux/crates/drivers/mssql/src/ddl.rs index f49f8d1c7c..7b9e9dd59c 100644 --- a/linux/crates/drivers/mssql/src/ddl.rs +++ b/linux/crates/drivers/mssql/src/ddl.rs @@ -201,6 +201,10 @@ impl DdlDialect for MssqlDialect { } parts.push(if draft.nullable { "NULL" } else { "NOT NULL" }.to_owned()); statements.push(alter(parts.join(" "))); + // ALTER COLUMN takes a mask only in a statement of its own. + if let Some(function) = &attributes.masking_function { + statements.push(alter(format!("ALTER COLUMN {name} ADD {}", masked_with(function)))); + } } if changed.default { let column_type = draft.column_type.column_type(self, &draft.name)?; @@ -251,9 +255,9 @@ impl DdlDialect for MssqlDialect { } impl MssqlDialect { - /// `name type [COLLATE c] [SPARSE] [IDENTITY(1,1)] NULL|NOT NULL - /// [DEFAULT (..)]`, or a computed column read from the catalogue as - /// `name AS (..)`. + /// `name type [COLLATE c] [SPARSE] [MASKED WITH (..)] [IDENTITY(1,1)] + /// NULL|NOT NULL [DEFAULT (..)]`, or a computed column read from the + /// catalogue as `name AS (..)`. fn column_definition(&self, column: &DraftColumn) -> Result { let name = self.quote_identifier(&column.name); let attributes = column.original.as_ref().map(|original| &original.attributes); @@ -279,6 +283,7 @@ impl MssqlDialect { if attributes.sparse { parts.push("SPARSE".to_owned()); } + parts.extend(attributes.masking_function.as_deref().map(masked_with)); } let identity = column.auto_increment || attributes.is_some_and(|attributes| attributes.identity == IdentityStyle::MssqlIdentity); @@ -342,6 +347,10 @@ fn collation_permission(attributes: &ColumnAttributes) -> Permission { } } +fn masked_with(function: &str) -> String { + format!("MASKED WITH (FUNCTION = {})", national_literal(function)) +} + fn plain_name(name: &str) -> bool { !name.is_empty() && name @@ -562,6 +571,7 @@ mod tests { original.columns.push(weight); let mut code = column("code", "varchar(10)", None); code.attributes.collation = Some(CollationName::new(None, "Latin1_General_BIN")); + code.attributes.masking_function = Some("partial(1,\"X's\",0)".to_owned()); original.columns.push(code); let plan = alter(&original, |draft| { @@ -576,13 +586,15 @@ mod tests { "ALTER TABLE [dbo].[orders] ALTER COLUMN [note] nvarchar(20) COLLATE Latin1_General_100_CS_AS NOT NULL", "ALTER TABLE [dbo].[orders] ALTER COLUMN [weight] bigint SPARSE NULL", "ALTER TABLE [dbo].[orders] ALTER COLUMN [code] int NULL", + "ALTER TABLE [dbo].[orders] ALTER COLUMN [code] ADD MASKED WITH (FUNCTION = N'partial(1,\"X''s\",0)')", ] ); assert_eq!( sql(dialect().create_table_script(&original))[0], "CREATE TABLE [dbo].[orders] (\n [id] int NOT NULL,\n [status] int NULL DEFAULT ((0)),\n [note] \ nvarchar(20) COLLATE Latin1_General_100_CS_AS NULL,\n [weight] int SPARSE NULL,\n [code] varchar(10) \ - COLLATE Latin1_General_BIN NULL,\n PRIMARY KEY ([id])\n)" + COLLATE Latin1_General_BIN MASKED WITH (FUNCTION = N'partial(1,\"X''s\",0)') NULL,\n PRIMARY KEY \ + ([id])\n)" ); } diff --git a/linux/crates/drivers/mssql/tests/integration/ddl.rs b/linux/crates/drivers/mssql/tests/integration/ddl.rs index 0f58d2334c..bad78cb4f3 100644 --- a/linux/crates/drivers/mssql/tests/integration/ddl.rs +++ b/linux/crates/drivers/mssql/tests/integration/ddl.rs @@ -14,7 +14,7 @@ async fn read(connection: &dyn Connection) -> TableDefinition { } /// ALTER COLUMN gives the column the database's collation and drops -/// SPARSE unless the statement restates them. +/// SPARSE and a data mask unless the plan restates them. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn nullability_change_keeps_column_collation() { @@ -24,7 +24,8 @@ async fn nullability_change_keeps_column_collation() { connection.as_ref(), &[ "CREATE TABLE dbo.kept (id int PRIMARY KEY, code varchar(20) COLLATE Latin1_General_BIN NULL, \ - name nvarchar(40) COLLATE Latin1_General_100_CS_AS NOT NULL, weight int SPARSE NULL)", + name nvarchar(40) COLLATE Latin1_General_100_CS_AS NOT NULL, weight int SPARSE NULL, \ + email varchar(50) MASKED WITH (FUNCTION = 'partial(1,\"X''s\",2)') NULL)", ], ) .await; @@ -35,6 +36,7 @@ async fn nullability_change_keeps_column_collation() { "code" => column.nullable = false, "name" => column.column_type = DraftType::Typed("nvarchar(80)".to_owned()), "weight" => column.column_type = DraftType::Typed("bigint".to_owned()), + "email" => column.column_type = DraftType::Typed("varchar(80)".to_owned()), _ => {} } } @@ -66,4 +68,12 @@ async fn nullability_change_keeps_column_collation() { assert_eq!(column("name").info.column_type.name, "nvarchar(80)"); assert!(column("weight").attributes.sparse); assert_eq!(column("weight").info.column_type.name, "bigint"); + let mask = |definition: &TableDefinition| { + definition + .column("email") + .and_then(|column| column.attributes.masking_function.clone()) + }; + assert!(mask(&before).is_some_and(|function| function.starts_with("partial("))); + assert_eq!(mask(&after), mask(&before)); + assert_eq!(column("email").info.column_type.name, "varchar(80)"); } From dd8f4cc8edda2cf36b17990d76ea116b1e9a936f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:56:41 +0700 Subject: [PATCH 136/178] docs(linux): record which column attributes each engine restates --- linux/CHANGELOG.md | 4 +++- linux/docs/adding-drivers.md | 2 +- .../docs/decisions/0010-ddl-plans-and-table-definitions.md | 7 ++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index bfc60fefcc..59f09d1629 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -59,7 +59,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Turning a column's primary key on or off in the Structure tab saved nothing. - Dropping a SQL Server column that has a default failed. - Creating a SQLite table with a foreign key failed. -- Changing a MySQL column in the Structure tab dropped its collation, ON UPDATE, INVISIBLE and SRID, and a comment turned a generated column into a plain one. +- Changing a column in the Structure tab lost its collation, SRID, SPARSE, CHECK or other attributes it had. +- Saving a MySQL column whose default or generated expression held a quoted string failed. - Save in a Structure tab's close prompt skipped the tab's checks and dropped a default that did not parse. - The Structure tab refused every save on a SQLite table keyed by INTEGER PRIMARY KEY. - Saved tabs and editor text were lost on a disconnect or window close right after a change. @@ -79,3 +80,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Importing a CSV or a connections file from a pipe or a device never finished, a CSV could read past its size limit, and a connections file had none. - SQL run in the editor, a typed filter or a column default, and the values in them, reached the journal at debug level. - Column types and defaults typed in the Structure tab reached the SQL after a text filter instead of the database's own parser. +- Changing a SQL Server column's type or nullability in the Structure tab removed its data mask. diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index daad2fd7c3..fa224945ec 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -314,7 +314,7 @@ A run streams `ResultEvent`s into a `ResultSink`: | Method | What it does | |---|---| | `capabilities` | The table-level `DdlCapabilities`. The tab offers only what is on. | -| `column_capabilities` | A `Permission` per change to one column. Start from `ColumnCapabilities::standard`, which refuses a restatement that would drop a generated expression or an unmodeled attribute, a default on a numbered column, a drop of a column another table's foreign key points at, and any primary-key change while a foreign key points at the key. Add the engine's own refusals on top, each with its `RefusalReason`. An engine that writes the whole column again to set its comment, as MySQL does, refuses `alter_comment` where `ColumnCapabilities::restatement` does. | +| `column_capabilities` | A `Permission` per change to one column. Start from `ColumnCapabilities::standard`, which refuses a restatement that would drop a generated expression or an unmodeled attribute, a default on a numbered column, a drop of a column another table's foreign key points at, and any primary-key change while a foreign key points at the key. Add the engine's own refusals on top, each with its `RefusalReason`. An engine that writes the whole column again, as MySQL does for every change and SQL Server for a type or nullability change, refuses those changes where it cannot write the column back as it is. One that restates a generated column's expression checks `ColumnCapabilities::modeled` in place of `restatement`. | | `type_suggestions`, `default_column_type`, `referential_actions` | What the tab's type menu, new columns and foreign-key dialog offer. Every suggestion has to parse through `parse_type`. | | `plan_create_table(&TableDraft)` | Starts with `draft.check_create(self)?`. Foreign keys go inside CREATE TABLE. | | `plan_alter_table(&TableDefinition, &TableChanges)` | Starts with `changes.check(self, original)?`, then writes the changes in the order the engine needs: one combined ALTER TABLE where the engine takes one, renames first, and every statement after a table rename against the new name. A table rename keeps the table in its schema. | diff --git a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md index 78c792680f..309f088a85 100644 --- a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md +++ b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md @@ -17,7 +17,7 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` **Refused before Save.** `DdlCapabilities` and `ColumnCapabilities` answer which changes the engine takes, each refusal with a `RefusalReason` the tab shows beside the control it turns off. A primary key another table's foreign key points at cannot move on any engine, and a column such a key references cannot be dropped. Every plan runs the same checks first, so a caller that skips the UI gets `BuildDdlError::Refused` or `Unsupported` rather than SQL the server rejects. -**Nothing spliced unchecked.** A catalogue type is spliced as the server gave it. A typed one is spliced only after `SqlDialect::parse_type` accepts it, a typed default only after `parse_expression` does, and a literal default goes through `SqlDialect::literal`. Every name goes through the dialect's quoting. +**Nothing spliced unchecked.** A catalogue type, generated expression or CHECK clause is spliced as the server gave it. A typed one is spliced only after `SqlDialect::parse_type` accepts it, a typed default only after `parse_expression` does, and a literal default goes through `SqlDialect::literal`. Every name goes through the dialect's quoting, except a SQL Server collation, which T-SQL takes only bare and which is spliced only when it is letters, digits and underscores. **The connection that runs it.** The save plans again inside the session operation, with the dialect of the lease that writes it, so a reconnect to another server version between edit and Save cannot send SQL spelled for the old one. @@ -27,9 +27,10 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` - An engine's DDL is one file in its driver crate, and core has no branch on a driver id. - The structure tab's controls follow the capabilities, so a change the engine cannot make is never offered. -- MySQL writes a column again whole for any change to it, its comment included, so it restates every attribute the definition models: the character set and collation, ON UPDATE, INVISIBLE and SRID where the new type still holds them. It refuses the change on a generated column or one with an attribute this build does not model. +- MySQL writes a column again whole for any change to it, its comment included, and SQL Server's ALTER COLUMN carries nothing forward either, so both restate every attribute the definition models where the new type still holds it. On MySQL that is the character set and collation, a generated column's expression, ON UPDATE, INVISIBLE and the SRID; MariaDB adds its REF_SYSTEM_ID and the column's own CHECK, which it keeps apart from the table's. On SQL Server it is the collation, SPARSE, the nullability and a data mask, which goes back on in a statement of its own. +- A PostgreSQL type change names the column's collation when the new type holds text, since SET DATA TYPE otherwise takes the type's default. +- A column with an attribute this build does not model, or one whose name the restatement cannot write, refuses every change that would restate it. - Primary-key identity on PostgreSQL and SQLite table rebuilds are plans of their own, added to the same dialects later, and their capabilities are off until then. -- The rest of the attribute restatement is not written yet and its changes stay on: a SQL Server ALTER COLUMN does not restate COLLATE or SPARSE, and a PostgreSQL type change does not restate a column's collation, so either change resets them. ## Alternatives considered From 6dba5e2beda685c13cf38089dc6ea5f1b412a4c4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 00:58:50 +0700 Subject: [PATCH 137/178] fix(linux): read a clicked close-prompt response before its close --- .../app/src/workspace/dialog_response.rs | 82 +++++++++++++++++-- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/linux/crates/app/src/workspace/dialog_response.rs b/linux/crates/app/src/workspace/dialog_response.rs index 998f408d10..f02541e19d 100644 --- a/linux/crates/app/src/workspace/dialog_response.rs +++ b/linux/crates/app/src/workspace/dialog_response.rs @@ -3,6 +3,7 @@ use std::rc::Rc; use futures::channel::oneshot; use gtk4 as gtk; +use gtk4::glib; use libadwaita as adw; use libadwaita::prelude::*; @@ -24,23 +25,36 @@ pub(crate) async fn respond(dialog: &adw::AlertDialog, parent: >k::Widget) -> }); let closed_fallback = fallback.clone(); dialog.connect_closed(move |_| { - if let Some(sender) = sender.borrow_mut().take() { - let _ = sender.send(closed_fallback.clone()); - } + // AdwAlertDialog emits `closed` before the response a click + // chose, so the close response waits until that has had its turn. + let sender = sender.clone(); + let fallback = closed_fallback.clone(); + glib::idle_add_local_once(move || { + if let Some(sender) = sender.borrow_mut().take() { + let _ = sender.send(fallback); + } + }); }); dialog.present(Some(parent)); receiver.await.unwrap_or(fallback) } -/// Answer the alert `window` shows with `response`, the way a click -/// would: the response, then the close. +/// Answer the alert `window` shows with `response` by clicking its +/// button, so the signals come in the order a user's click sends them. #[cfg(test)] pub(crate) fn answer(window: &impl IsA, response: &str) -> bool { - let Some(dialog) = window.as_ref().visible_dialog().and_downcast::() else { + let Some(dialog) = visible_alert(window) else { + return false; + }; + let label = dialog.response_label(response); + let Some(button) = crate::test_support::descendants(&dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some(label.as_str())) + else { return false; }; - dialog.emit_by_name::<()>("response", &[&response]); - dialog.force_close(); + button.emit_clicked(); true } @@ -49,3 +63,55 @@ pub(crate) fn answer(window: &impl IsA, response: &str) -> bool { pub(crate) fn visible_alert(window: &impl IsA) -> Option { window.as_ref().visible_dialog().and_downcast::() } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::test_support::wait_until; + + const WAIT: Duration = Duration::from_secs(10); + + fn alert(window: &adw::Window) -> (adw::AlertDialog, Rc>>) { + let dialog = adw::AlertDialog::new(Some("Saving Changes"), None); + dialog.add_response("keep", "_Keep Open"); + dialog.add_response("stop", "_Stop Saving"); + dialog.set_close_response("keep"); + let answer = Rc::new(RefCell::new(None)); + let sink = answer.clone(); + let (shown, parent) = (dialog.clone(), window.clone().upcast::()); + glib::spawn_future_local(async move { + sink.replace(Some(respond(&shown, &parent).await)); + }); + wait_until(WAIT, || visible_alert(window).is_some()).expect("the alert"); + (dialog, answer) + } + + #[gtk4::test] + fn a_clicked_response_answers_and_closes_the_alert() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = adw::Window::new(); + window.present(); + let (_dialog, answered) = alert(&window); + + assert!(answer(&window, "stop")); + + wait_until(WAIT, || answered.borrow().is_some()).expect("an answer"); + assert_eq!(answered.borrow().as_deref(), Some("stop")); + wait_until(WAIT, || visible_alert(&window).is_none()).expect("the alert to close itself"); + } + + #[gtk4::test] + fn an_alert_forced_shut_answers_with_its_close_response() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = adw::Window::new(); + window.present(); + let (dialog, answer) = alert(&window); + + dialog.force_close(); + + wait_until(WAIT, || answer.borrow().is_some()).expect("an answer"); + assert_eq!(answer.borrow().as_deref(), Some("keep")); + } +} From f684f3d8f82daf4af068876c0c90bd94df06882d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:10:47 +0700 Subject: [PATCH 138/178] fix(linux-grid): leave server-filled columns out of new rows --- linux/CHANGELOG.md | 4 +- .../crates/app/src/services/change_tracker.rs | 45 ++++++----- .../app/src/services/tab_tracker_handle.rs | 5 ++ linux/crates/app/src/ui/browse_tab/mod.rs | 27 +++++-- linux/crates/app/src/ui/browse_tab/tests.rs | 49 ++++++++++++ .../app/src/ui/grid/cell_editability.rs | 28 ++++--- linux/crates/core/src/export/mod.rs | 2 + .../core/src/export/render_insert_error.rs | 13 ++++ linux/crates/core/src/export/sql.rs | 74 +++++++++++++++---- 9 files changed, 197 insertions(+), 50 deletions(-) create mode 100644 linux/crates/core/src/export/render_insert_error.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 281df0c2ad..fddd47ac71 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Structure tab offers only the changes the database can make, and says why a control is off. -- New rows in the table grid take a value for the primary key and auto-increment columns. +- New rows in the table grid take a value for a primary key that is not auto-increment. - Show CREATE TABLE gives the server's own statement on MySQL, ClickHouse and SQLite. ### Fixed @@ -40,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ClickHouse save with an unknown outcome could read as applied because of another table's mutation. - A new row removed with Delete stayed in the grid while the save left it out. - An edit, Set Value or row action in the table grid could land on another row once a row above it came or went. -- Copy as INSERT could copy the wrong row, and left out the schema and unsaved edits but kept generated columns. +- Copy as INSERT could copy the wrong row, drop the schema or unsaved edits, or write a value where the server fills one. - Duplicate left out the row's unsaved edits. - Cells of a table without a primary key opened for editing, then dropped what was typed. - A save that failed on a new row did not flash that row. diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index 646731b0f6..7c52efff63 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -448,6 +448,14 @@ impl TabChangeTracker { .map(|draft| draft.values.as_slice()) } + /// A draft's cells as its INSERT carries them. + pub fn draft_cells(&self, draft_id: u64, columns: &[ColumnInfo]) -> Option> { + self.drafts + .iter() + .find(|draft| draft.draft_id == draft_id) + .map(|draft| insert_cells(columns, draft)) + } + /// What the user changed, as the change set a save is planned from. /// /// One change per row, ordered by when the row was last touched, so @@ -461,14 +469,11 @@ impl TabChangeTracker { pub fn change_set(&self, columns: &[ColumnInfo]) -> Result { let mut rows: Vec<(Touch, RowChange, StatementSource)> = Vec::new(); for draft in &self.drafts { - let cells = columns - .iter() - .zip(&draft.values) - .map(|(column, value)| draft_cell(column, value)) - .collect(); rows.push(( draft.touched, - RowChange::Insert { cells }, + RowChange::Insert { + cells: insert_cells(columns, draft), + }, StatementSource::Insert { draft_id: draft.draft_id, }, @@ -569,15 +574,22 @@ fn key_values(key: &[KeyValue]) -> Vec { key.iter().map(|part| part.value().clone()).collect() } +fn insert_cells(columns: &[ColumnInfo], draft: &DraftRow) -> Vec { + columns + .iter() + .zip(&draft.values) + .map(|(column, value)| draft_cell(column, value)) + .collect() +} + /// A NULL the user left in a column with a default means "use the -/// default" (CURRENT_TIMESTAMP, gen_random_uuid(), the next serial), -/// not an explicit NULL over it. A value typed into an auto-increment -/// column is the user's to keep. +/// default" (CURRENT_TIMESTAMP, gen_random_uuid()), not an explicit +/// NULL over it. fn draft_cell(column: &ColumnInfo, value: &Value) -> CellInput { - if column.is_generated { + if column.is_auto_increment || column.is_generated { return CellInput::Default; } - if matches!(value, Value::Null) && (column.is_auto_increment || !column.default.is_none()) { + if matches!(value, Value::Null) && !column.default.is_none() { return CellInput::Default; } CellInput::Value(value.clone()) @@ -1106,22 +1118,21 @@ mod tests { } #[test] - fn a_draft_keeps_a_value_typed_into_an_auto_increment_column() { + fn a_draft_leaves_an_auto_increment_column_to_the_server() { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("name")]; - t.track_insert(vec![Value::Int(42), Value::Text("draft".into())]); + let draft_id = insert(&mut t, vec![Value::Int(42), Value::Text("draft".into())]); let tracked = t.change_set(&columns).unwrap(); + let expected = vec![CellInput::Default, CellInput::Value(Value::Text("draft".into()))]; assert_eq!( tracked.changes.changes, vec![RowChange::Insert { - cells: vec![ - CellInput::Value(Value::Int(42)), - CellInput::Value(Value::Text("draft".into())) - ] + cells: expected.clone() }] ); + assert_eq!(t.draft_cells(draft_id, &columns), Some(expected)); } #[test] diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs index 7696e13772..4e8acfefd9 100644 --- a/linux/crates/app/src/services/tab_tracker_handle.rs +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -2,6 +2,7 @@ use std::cell::RefCell; use std::rc::Rc; use tablepro_core::dml::BuildSqlError; +use tablepro_core::edit::CellInput; use tablepro_core::{ColumnInfo, Value}; use super::cell_overlay::CellOverlay; @@ -51,6 +52,10 @@ impl TabTrackerHandle { self.0.borrow().draft_values(draft_id).map(<[Value]>::to_vec) } + pub fn draft_cells(&self, draft_id: u64, columns: &[ColumnInfo]) -> Option> { + self.0.borrow().draft_cells(draft_id, columns) + } + pub fn change_set(&self, columns: &[ColumnInfo]) -> Result { self.0.borrow().change_set(columns) } diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index f6761d936f..ab4d2ff5f5 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -26,6 +26,7 @@ use libadwaita as adw; use libadwaita::prelude::*; use tablepro_core::dialect::SqlDialect; use tablepro_core::edit::{CellInput, EditContext, EditParseError}; +use tablepro_core::export::RenderInsertError; use tablepro_core::meta::{RowIdentity, TableRef}; use tablepro_core::{ColumnInfo, QueryResult, Value}; use tablepro_session::runtime::{Tasks, WatchForwarder}; @@ -1774,7 +1775,8 @@ impl BrowseModel { } /// One row as an INSERT for the server the tab is connected to, - /// pending edits included. + /// pending edits included. A draft's is the INSERT its save would + /// run, so the columns the server fills stay out of it. fn copy_row_as_insert(&self, row: &RowObject, fx: &mut Effects) { let Some(dialect) = self.session.current_dialect() else { fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( @@ -1782,14 +1784,29 @@ impl BrowseModel { ))); return; }; - let cells = self.grid_context().effective_cells(row); + let cells = match row.id() { + GridRowId::Draft(draft_id) => self + .tracker + .draft_cells(*draft_id, &self.current_columns) + .unwrap_or_default(), + _ => self + .grid_context() + .effective_cells(row) + .into_iter() + .map(CellInput::Value) + .collect(), + }; match tablepro_core::export::render_insert(&*dialect, &self.table_ref(), &self.current_columns, &cells) { Ok(sql) => fx.output(BrowseTabOutput::CopyToClipboard(sql)), Err(error) => { tracing::debug!(%error, "a row could not be written as an INSERT"); - fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( - "This row has a value that can't be written as SQL", - ))); + let message = match error { + RenderInsertError::Literal(_) => { + crate::i18n::gettext("This row has a value that can't be written as SQL") + } + RenderInsertError::NoValues => crate::i18n::gettext("This row has no value to put in an INSERT"), + }; + fx.output(BrowseTabOutput::ShowToast(message)); } } } diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index dab27880f3..f83cd36fc6 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -842,3 +842,52 @@ fn copy_row_as_insert_uses_row_below_draft() { ["INSERT INTO \"users\" (\"id\", \"name\") VALUES (0, 'Ada');".to_owned()] ); } + +#[gtk4::test] +fn copy_row_as_insert_on_a_draft_leaves_what_the_server_fills_out() { + let mut id = col("integer", false); + id.name = "id".to_owned(); + id.primary_key = true; + id.is_auto_increment = true; + let mut name = col("text", true); + name.name = "name".to_owned(); + let mut created_at = col_with_default("timestamptz", "now()"); + created_at.name = "created_at".to_owned(); + let columns = vec![id, name, created_at]; + let mut script = ConnectionScript::default(); + script.fetch_columns.always(FakeReply::ok(columns.clone())); + script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + })); + script.count_rows.always(FakeReply::ok(1)); + script.probe.always(FakeReply::ok(())); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(1), Value::Text("Ada".into()), Value::Null]], + ))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let copied = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = copied.clone(); + harness + .tab() + .connect_copy_to_clipboard(move |_, text| sink.borrow_mut().push(text)); + let toasts = toasts(harness.tab()); + + harness.tab().send(BrowseTabInput::InsertRow); + let draft = harness.grid_row(0); + assert!(draft.draft_id().is_some(), "the new row goes on top"); + harness.tab().send(BrowseTabInput::GridCellEdited { + row: draft.clone(), + column: 1, + input: "x".to_owned(), + }); + harness.tab().send(BrowseTabInput::GridCopyRowAsInsert { row: draft }); + + assert_eq!( + *copied.borrow(), + ["INSERT INTO \"users\" (\"name\") VALUES ('x');".to_owned()], + "toasts: {:?}", + toasts.borrow() + ); +} diff --git a/linux/crates/app/src/ui/grid/cell_editability.rs b/linux/crates/app/src/ui/grid/cell_editability.rs index 6b0eb04fce..a4016b0d47 100644 --- a/linux/crates/app/src/ui/grid/cell_editability.rs +++ b/linux/crates/app/src/ui/grid/cell_editability.rs @@ -4,8 +4,10 @@ use tablepro_core::meta::RowIdentity; use super::{CellLock, GridRowId}; -/// Whether one cell of one row takes an edit. A draft is a new row, so -/// it may set the key and auto-increment columns a saved row may not. +/// A draft is a new row, so it may set the key a saved row may not. +/// An auto-increment column stays locked on a draft too: an insert +/// leaves it to the server, and SQL Server IDENTITY and PostgreSQL +/// GENERATED ALWAYS refuse a value without a statement-level override. pub fn cell_editability( column: &ColumnInfo, row: &GridRowId, @@ -26,13 +28,11 @@ pub fn cell_editability( if let Some(reason) = read_only_reason(&column.column_type.kind) { return Err(CellLock::Value(reason)); } - if matches!(row, GridRowId::Persisted(_)) { - if names_rows(column, identity) { - return Err(CellLock::KeyColumn); - } - if column.is_auto_increment { - return Err(CellLock::AutoIncrement); - } + if column.is_auto_increment { + return Err(CellLock::AutoIncrement); + } + if matches!(row, GridRowId::Persisted(_)) && names_rows(column, identity) { + return Err(CellLock::KeyColumn); } Ok(()) } @@ -110,7 +110,11 @@ mod tests { assert_eq!(check(&total, &draft, false), Err(CellLock::Generated)); assert_eq!(check(&total, &persisted(), false), Err(CellLock::Generated)); assert_eq!(check(&serial, &persisted(), false), Err(CellLock::AutoIncrement)); - assert_eq!(check(&serial, &draft, false), Ok(())); + assert_eq!( + check(&serial, &draft, false), + Err(CellLock::AutoIncrement), + "an insert leaves an auto-increment column to the server" + ); assert_eq!(check(&name, &GridRowId::Unkeyed, false), Err(CellLock::UnkeyedRow)); assert_eq!(check(&name, &GridRowId::Result, false), Err(CellLock::ResultRow)); assert_eq!(check(&name, &persisted(), true), Err(CellLock::ReadOnlySession)); @@ -140,8 +144,12 @@ mod tests { let mut total = column("total", "integer"); total.is_generated = true; + let mut serial = column("seq", "integer"); + serial.is_auto_increment = true; + assert!(column_takes_edits(&column("id", "integer"), &identity, false)); assert!(!column_takes_edits(&total, &identity, false)); + assert!(!column_takes_edits(&serial, &identity, false)); assert!(!column_takes_edits(&column("data", "blob"), &identity, false)); assert!(!column_takes_edits(&column("name", "text"), &identity, true)); } diff --git a/linux/crates/core/src/export/mod.rs b/linux/crates/core/src/export/mod.rs index e910a2e5db..05b46ad821 100644 --- a/linux/crates/core/src/export/mod.rs +++ b/linux/crates/core/src/export/mod.rs @@ -5,6 +5,7 @@ mod in_clause; mod json; mod json_field_names; mod markdown; +mod render_insert_error; mod sql; mod tsv; mod value_text; @@ -23,6 +24,7 @@ pub use in_clause::{InClause, render_in_clause}; pub use json::{render_json, row_to_json}; pub use json_field_names::json_field_names; pub use markdown::render_markdown; +pub use render_insert_error::RenderInsertError; pub use sql::{literal, render_insert, render_sql_insert}; pub use tsv::{render_tsv, tsv_writer_builder}; pub use value_text::{value_text, value_to_text}; diff --git a/linux/crates/core/src/export/render_insert_error.rs b/linux/crates/core/src/export/render_insert_error.rs new file mode 100644 index 0000000000..1a04db7bc6 --- /dev/null +++ b/linux/crates/core/src/export/render_insert_error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +use crate::dialect::LiteralError; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum RenderInsertError { + #[error(transparent)] + Literal(#[from] LiteralError), + /// Every column is left to the server, and the engine has no + /// spelling for a row with no values. + #[error("there is nothing to insert")] + NoValues, +} diff --git a/linux/crates/core/src/export/sql.rs b/linux/crates/core/src/export/sql.rs index 83659052eb..d7acf85f7e 100644 --- a/linux/crates/core/src/export/sql.rs +++ b/linux/crates/core/src/export/sql.rs @@ -1,5 +1,7 @@ +use super::RenderInsertError; use crate::column::{ColumnInfo, ResultColumn}; -use crate::dialect::{LiteralError, SqlDialect}; +use crate::dialect::SqlDialect; +use crate::edit::CellInput; use crate::meta::TableRef; use crate::value::Value; @@ -54,14 +56,15 @@ pub fn render_sql_insert( /// /// Unlike an export, a value with no literal is an error rather than a /// marked `NULL`: a single statement copied to run again is worth -/// nothing with a cell quietly swapped out. Generated columns are left -/// out, since the server refuses a value for them. +/// nothing with a cell quietly swapped out. A `Default` cell and a +/// generated column are left out, as a save leaves them, so the server +/// fills them. pub fn render_insert( dialect: &dyn SqlDialect, table: &TableRef, columns: &[ColumnInfo], - row: &[Value], -) -> Result { + cells: &[CellInput], +) -> Result { let table_sql = dialect.qualify(table); let mut names = Vec::with_capacity(columns.len()); let mut values = Vec::with_capacity(columns.len()); @@ -69,17 +72,19 @@ pub fn render_insert( if !column.is_writable() { continue; } - names.push(dialect.quote_identifier(&column.name)); - values.push(match row.get(index) { - Some(value) => dialect.literal(value, &column.column_type)?, + let value = match cells.get(index) { + Some(CellInput::Default) => continue, + Some(CellInput::Value(value)) => dialect.literal(value, &column.column_type)?, None => "NULL".to_owned(), - }); + }; + names.push(dialect.quote_identifier(&column.name)); + values.push(value); } if names.is_empty() { - return Ok(dialect + let sql = dialect .insert_default_row(&table_sql) - .map(|sql| format!("{sql};")) - .unwrap_or_default()); + .ok_or(RenderInsertError::NoValues)?; + return Ok(format!("{sql};")); } Ok(format!( "INSERT INTO {table_sql} ({}) VALUES ({});", @@ -101,7 +106,7 @@ pub fn literal(dialect: &dyn SqlDialect, value: &Value, column: &ResultColumn) - mod tests { use super::super::test_columns::cols; use super::*; - use crate::dialect::TestDialect; + use crate::dialect::{LiteralError, TestDialect}; fn render(table: &str, columns: &[ResultColumn], rows: &[Vec]) -> String { render_sql_insert(&TestDialect::default(), table, columns, rows) @@ -187,6 +192,10 @@ mod tests { } } + fn values(row: &[Value]) -> Vec { + row.iter().cloned().map(CellInput::Value).collect() + } + #[test] fn render_insert_qualifies_schema_and_skips_generated() { let columns = [column("id", false), column("total", true), column("name", false)]; @@ -196,7 +205,7 @@ mod tests { &TestDialect::default(), &TableRef::new(Some("sales".to_owned()), "orders"), &columns, - &row, + &values(&row), ); assert_eq!( @@ -211,9 +220,42 @@ mod tests { &TestDialect::default(), &TableRef::unqualified("t"), &[column("blob", false)], - &[Value::Bytes(vec![0xde, 0xad])], + &values(&[Value::Bytes(vec![0xde, 0xad])]), ); - assert!(matches!(sql, Err(LiteralError::Unrepresentable { .. })), "{sql:?}"); + assert!( + matches!( + sql, + Err(RenderInsertError::Literal(LiteralError::Unrepresentable { .. })) + ), + "{sql:?}" + ); + } + + #[test] + fn render_insert_leaves_a_default_cell_to_the_server() { + let columns = [column("id", false), column("name", false), column("created_at", false)]; + let cells = [ + CellInput::Default, + CellInput::Value(Value::Text("x".into())), + CellInput::Default, + ]; + + let sql = render_insert(&TestDialect::default(), &TableRef::unqualified("t"), &columns, &cells); + + assert_eq!(sql.as_deref(), Ok("INSERT INTO \"t\" (\"name\") VALUES ('x');")); + } + + #[test] + fn render_insert_with_nothing_to_write_needs_a_default_row_form() { + let columns = [column("id", false)]; + let cells = [CellInput::Default]; + let table = TableRef::unqualified("t"); + + let with_form = render_insert(&TestDialect::default(), &table, &columns, &cells); + let without = render_insert(&TestDialect::default().without_default_row(), &table, &columns, &cells); + + assert_eq!(with_form.as_deref(), Ok("INSERT INTO \"t\" DEFAULT VALUES;")); + assert_eq!(without, Err(RenderInsertError::NoValues)); } } From 9a529d5e4fd32e025d6f4553ce3d573b52cd5c3d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:10:50 +0700 Subject: [PATCH 139/178] fix(linux-grid): hand grid events to the tab after the signal returns --- linux/crates/app/src/ui/browse_tab/mod.rs | 12 ++++-- linux/crates/app/src/ui/browse_tab/tests.rs | 41 +++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index ab4d2ff5f5..ffb53f4be5 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -148,12 +148,18 @@ impl BrowseTab { tab.collapse_top_bars_with(&filter_strip.widget); tab.connect_widgets(); + // Delivered once the signal that sent it returns: a commit + // refreshes its own row, which replaces the row's widgets, and + // the Tab that ended the edit still has to move focus from them. let grid_sink: GridSink = { let tab = tab.downgrade(); Rc::new(move |msg| { - if let Some(tab) = tab.upgrade() { - tab.send(BrowseTabInput::from(msg)); - } + let tab = tab.clone(); + glib::spawn_future_local(async move { + if let Some(tab) = tab.upgrade() { + tab.send(BrowseTabInput::from(msg)); + } + }); }) }; let link_generation = init.session.link_generation(); diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index f83cd36fc6..e3ec584b6f 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -843,6 +843,47 @@ fn copy_row_as_insert_uses_row_below_draft() { ); } +/// The cell showing `column` of the saved row keyed `id`. +fn grid_cell_of(tab: &BrowseTab, id: i64, column: usize) -> Option { + let key = RowKey::from_pk_values(&[Value::Int(id)])?; + crate::test_support::descendants(tab) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.column() == column && cell.row().is_some_and(|row| row.key() == Some(&key))) +} + +#[gtk4::test] +fn a_commit_leaves_its_cell_in_the_window_for_the_tab_that_follows() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let window = gtk4::Window::new(); + window.set_default_size(600, 400); + window.set_child(Some(harness.tab())); + window.present(); + let mut found = None; + wait_until(WAIT, || { + found = grid_cell_of(harness.tab(), 0, 1); + found.is_some() + }) + .expect("Ada's name cell is drawn"); + let cell = found.expect("a cell"); + crate::ui::grid::enter_edit_mode(&cell); + let editor = cell.editor().expect("a text cell"); + editor.entry().set_text("Ada Lovelace"); + + editor.stop_editing(true); + + assert!( + cell.root().is_some(), + "the commit took its own cell out of the window before Tab could move on" + ); + wait_until(WAIT, || harness.tracker.pending_count() == 1).expect("the edit reaches the tracker"); + window.set_child(None::<>k4::Widget>); + window.destroy(); +} + #[gtk4::test] fn copy_row_as_insert_on_a_draft_leaves_what_the_server_fills_out() { let mut id = col("integer", false); From 53b075afcaaefe1c6fcc6315c01a35fdb7c47f7a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:10:57 +0700 Subject: [PATCH 140/178] fix(linux-grid): keep a locked key cell's text selectable --- linux/crates/app/src/ui/grid/cell_editor.rs | 13 +++++++-- .../crates/app/src/ui/grid/column_factory.rs | 28 +++++++++++++++++++ linux/crates/app/src/ui/grid/inline_edit.rs | 4 +++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/linux/crates/app/src/ui/grid/cell_editor.rs b/linux/crates/app/src/ui/grid/cell_editor.rs index c2bebccf3d..16641f8fba 100644 --- a/linux/crates/app/src/ui/grid/cell_editor.rs +++ b/linux/crates/app/src/ui/grid/cell_editor.rs @@ -1,6 +1,6 @@ -//! The display page is a plain label with no pointer controllers, so a -//! single click reaches the column view's row selection. The edit page -//! shows only after `start_editing`. +//! The display page is a plain label with no pointer controllers while +//! the cell takes edits, so a single click reaches the column view's row +//! selection. The edit page shows only after `start_editing`. use gtk4::prelude::*; use gtk4::subclass::prelude::*; @@ -133,6 +133,13 @@ impl CellEditor { self.entry_widget().set_text(s); } + pub fn set_selectable(&self, selectable: bool) { + let label = self.label_widget(); + if label.is_selectable() != selectable { + label.set_selectable(selectable); + } + } + /// The entry is left alone: a row marked for deletion is not edited. pub fn set_strikethrough(&self, on: bool) { let label = self.label_widget(); diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index 2e94a25a13..dd85f4eb09 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -292,6 +292,9 @@ fn draw_cell(grid_cell: &GridCell, value: &Value, pending_classes: &[&'static st }; if let Some(editor) = content.downcast_ref::() { editor.set_text(&text); + // A locked cell's text is there to be read and copied, as a + // read-only column's is. + editor.set_selectable(!editable); apply_cell_tooltip(content, &text, is_null); set_class(content, "dim-label", is_null && !editable); set_class(content, "tp-null-sentinel", is_null && (editable || auto_filled)); @@ -735,6 +738,31 @@ mod tests { assert!(!grid.cell_of(1, 0).editor().expect("a text cell").is_editing()); } + fn label_of(cell: &GridCell) -> gtk::Label { + descendants(cell) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("a cell draws its value in a label") + } + + #[gtk4::test] + fn a_locked_key_cell_keeps_its_text_selectable() { + let grid = two_text_rows(); + grid.insert_draft(); + let draft_key_cell = grid + .cells() + .into_iter() + .find(|cell| cell.column() == 0 && cell.row().is_some_and(|row| row.draft_id().is_some())) + .expect("the draft's key cell"); + + assert!( + label_of(&grid.cell_of(1, 0)).is_selectable(), + "a saved row's key cannot be selected" + ); + assert!(!label_of(&draft_key_cell).is_selectable()); + assert!(!label_of(&grid.cell_of(1, 1)).is_selectable()); + } + #[gtk4::test] fn edit_during_rebind_shows_pending_value() { let grid = two_text_rows(); diff --git a/linux/crates/app/src/ui/grid/inline_edit.rs b/linux/crates/app/src/ui/grid/inline_edit.rs index a5390ab3bf..50354dad67 100644 --- a/linux/crates/app/src/ui/grid/inline_edit.rs +++ b/linux/crates/app/src/ui/grid/inline_edit.rs @@ -62,6 +62,10 @@ pub(super) fn install_edit_triggers(cell: &GridCell, sink: GridSink, editor_kind let Some(cell) = gesture.widget().and_downcast::() else { return; }; + // Left to a locked cell's label, which selects a word with it. + if cell.lock().is_some() { + return; + } gesture.set_state(gtk::EventSequenceState::Claimed); trigger_for_press(&cell); }); From 0f143340a3725bc09e0dfa9fc64a8746ad688d54 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:10:58 +0700 Subject: [PATCH 141/178] refactor(linux-grid): drop doc comments that restate the code --- linux/crates/app/src/ui/grid/cell_lock.rs | 1 - linux/crates/app/src/ui/grid/column_factory.rs | 2 -- linux/crates/app/src/ui/grid/grid_cell.rs | 2 -- 3 files changed, 5 deletions(-) diff --git a/linux/crates/app/src/ui/grid/cell_lock.rs b/linux/crates/app/src/ui/grid/cell_lock.rs index dd58514572..5ad3165190 100644 --- a/linux/crates/app/src/ui/grid/cell_lock.rs +++ b/linux/crates/app/src/ui/grid/cell_lock.rs @@ -1,6 +1,5 @@ use tablepro_core::edit::ReadOnlyReason; -/// Why one cell of one row cannot be edited. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CellLock { /// The column's values have no text form to type. diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index dd85f4eb09..8998255428 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -29,8 +29,6 @@ const WIDE_TABLE_THRESHOLD: usize = 8; /// About 14 characters at the default font size. const MIN_COLUMN_WIDTH_PX: i32 = 120; -/// `result` gives the columns and whether the read was cut short; -/// `rows` are what the grid shows, each carrying its own identity. pub fn build_column_view( result: &QueryResult, rows: impl IntoIterator, diff --git a/linux/crates/app/src/ui/grid/grid_cell.rs b/linux/crates/app/src/ui/grid/grid_cell.rs index 74912cd8ac..801cead4ab 100644 --- a/linux/crates/app/src/ui/grid/grid_cell.rs +++ b/linux/crates/app/src/ui/grid/grid_cell.rs @@ -25,7 +25,6 @@ mod imp { /// IME popover ends the edit, and committing then would send the /// text from before the composition. pub preedit_active: Cell, - /// Why the row bound now cannot be edited here. pub lock: Cell>, } @@ -80,7 +79,6 @@ impl GridCell { self.imp().column.set(column); } - /// The widget that draws the value. Set once, at setup. pub fn set_content(&self, content: &impl IsA) { let content = content.as_ref(); if self.imp().content.set(content.clone()).is_ok() { From 3f0e619e56c4493c5269b88cc7cee11e2ab9f73b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:19:17 +0700 Subject: [PATCH 142/178] fix(linux): lock the structure draft while its save runs --- linux/CHANGELOG.md | 1 + .../app/src/services/structure_tracker.rs | 4 + .../src/services/structure_tracker_handle.rs | 4 + linux/crates/app/src/ui/structure_tab/mod.rs | 61 ++++++++++++- .../crates/app/src/ui/structure_tab/tests.rs | 86 ++++++++++++++++++- 5 files changed, 153 insertions(+), 3 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 11908e580b..4e21c95cf5 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -77,6 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Disconnecting or switching connections offered no way to save pending changes first. - A save whose answer was lost with the connection showed as failed, and saving again could apply it twice. - Edits made in a table tab while its save ran were dropped when the save finished. +- Structure tab edits made while its save ran were lost when the table was read again. - Ctrl+S did nothing in a Structure tab. ### Security diff --git a/linux/crates/app/src/services/structure_tracker.rs b/linux/crates/app/src/services/structure_tracker.rs index 492294ead6..9ea1b9714a 100644 --- a/linux/crates/app/src/services/structure_tracker.rs +++ b/linux/crates/app/src/services/structure_tracker.rs @@ -24,6 +24,10 @@ impl StructureChangeTracker { self.pending = pending; } + pub fn is_saving(&self) -> bool { + self.saving + } + pub fn pending_state(&self) -> PendingState { if let Some(cause) = &self.outcome_unknown { return PendingState::OutcomeUnknown(cause.clone()); diff --git a/linux/crates/app/src/services/structure_tracker_handle.rs b/linux/crates/app/src/services/structure_tracker_handle.rs index 4fa0a9cd0a..1bdf5fe3dc 100644 --- a/linux/crates/app/src/services/structure_tracker_handle.rs +++ b/linux/crates/app/src/services/structure_tracker_handle.rs @@ -27,6 +27,10 @@ impl StructureTrackerHandle { self.0.borrow().pending_state() } + pub fn is_saving(&self) -> bool { + self.0.borrow().is_saving() + } + pub fn has_pending(&self) -> bool { self.0.borrow().has_pending() } diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index c145290fea..a10124301f 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -489,6 +489,22 @@ pub(crate) enum StructureTabInput { TableNameEdited(String), } +impl StructureTabInput { + fn edits_draft(&self) -> bool { + matches!( + self, + Self::TableNameEdited(_) + | Self::ColumnEdited { .. } + | Self::AddColumn + | Self::RemoveColumn(_) + | Self::AddIndex(_) + | Self::RemoveIndex(_) + | Self::AddForeignKey(_) + | Self::RemoveForeignKey(_) + ) + } +} + #[derive(Debug, Clone)] pub enum ColumnField { Name(String), @@ -830,9 +846,9 @@ impl StructureModel { self.invalid_defaults.clear(); self.inner_stack.set_visible_child_name("editor"); self.definition_shown = true; - // The definition is current again, so the diff counts; it is - // empty now, which also clears anything typed during the read. + // The definition is current again, so the diff counts. self.refetching = false; + self.sync_editable(); self.recompute_dirty_state(fx); fx.input(StructureTabInput::Refresh); } @@ -853,6 +869,7 @@ impl StructureModel { // A retry from the error page must not find dirty tracking still // frozen. self.refetching = false; + self.sync_editable(); } fn on_command(&mut self, command: StructureCommand, fx: &mut StructureEffects) { @@ -898,6 +915,10 @@ impl StructureModel { } fn update(&mut self, msg: StructureTabInput, fx: &mut StructureEffects) { + if msg.edits_draft() && self.edits_locked() { + self.refuse_edit(fx); + return; + } match msg { StructureTabInput::Start => { if self.mode == StructureMode::Edit { @@ -1167,6 +1188,41 @@ impl StructureModel { self.save_button.set_sensitive(false); self.discard_button.set_sensitive(false); } + self.sync_editable(); + } + + /// The draft holds still from the moment a save takes it until the + /// table is read again after a commit. That read replaces the draft, + /// so an edit made before it would be lost without a prompt. + fn edits_locked(&self) -> bool { + self.saving || self.refetching || self.tracker.is_saving() + } + + fn sync_editable(&self) { + let editable = !self.saving && !self.refetching; + for widget in [ + self.name_row.upcast_ref::(), + self.columns_box.upcast_ref(), + self.indexes_box.upcast_ref(), + self.fks_box.upcast_ref(), + ] { + widget.set_sensitive(editable); + } + } + + /// An edit that reached the tab while its draft was locked, from a + /// dialog opened before the save: the widgets go back to the draft. + fn refuse_edit(&self, fx: &mut StructureEffects) { + let name = self.draft.borrow().table.name.clone(); + if self.name_entry.text() != name { + self.suppress_emit.set(true); + self.name_entry.set_text(&name); + self.suppress_emit.set(false); + } + fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext( + "Wait for the save to finish, then make the change again.", + ))); + fx.input(StructureTabInput::Refresh); } fn save_completed(&mut self, new_table_name: Option, fx: &mut StructureEffects) { @@ -1195,6 +1251,7 @@ impl StructureModel { fx.output(StructureTabOutput::DirtyChanged(false)); } self.refetching = true; + self.sync_editable(); self.definition_shown = false; self.fetch_definition(); } diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index 094ac78c5d..16917c7cc3 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -78,6 +78,34 @@ impl Harness { }) .unwrap_or_default() } + + fn comments(&self) -> Vec> { + self.tab() + .imp() + .model + .borrow() + .as_ref() + .map(|model| { + model + .draft + .borrow() + .columns + .iter() + .map(|column| column.comment.clone()) + .collect() + }) + .unwrap_or_default() + } + + fn tracker(&self) -> StructureTrackerHandle { + self.tab() + .imp() + .model + .borrow() + .as_ref() + .map(|model| model.tracker.clone()) + .expect("the tab has a model") + } } impl Drop for Harness { @@ -548,10 +576,21 @@ fn stop_saving_shows_only_while_a_save_runs() { harness.tab().send(StructureTabInput::SaveStarted); assert!(stop.is_visible() && imp.saving_spinner.is_visible()); + assert!( + !imp.columns_box.is_sensitive(), + "the columns stayed editable during the save" + ); + let toasts = toasts(harness.tab()); harness.tab().send(StructureTabInput::ColumnEdited { index: 0, field: ColumnField::Comment(Some("the key".to_owned())), }); + assert_eq!( + harness.comments(), + [Some("key".to_owned())], + "an edit got into a saving draft" + ); + assert_eq!(toasts.borrow().len(), 1, "a refused edit said nothing"); assert!(!save.is_sensitive(), "an edit during the save turned Save back on"); stop.emit_clicked(); assert_eq!(stops.get(), 1); @@ -560,5 +599,50 @@ fn stop_saving_shows_only_while_a_save_runs() { .tab() .send(StructureTabInput::SaveFailed("rolled back".to_owned())); assert!(!stop.is_visible() && !imp.saving_spinner.is_visible()); - assert!(save.is_sensitive()); + assert!(save.is_sensitive() && imp.columns_box.is_sensitive()); +} + +/// The read after a commit replaces the draft, so the draft takes no +/// edit from the moment the save takes it until that read lands: one +/// made in between would be lost, and a close waiting on the save would +/// find nothing to ask about. +#[gtk4::test] +fn an_edit_waits_for_the_read_after_a_committed_save() { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(TableDefinition { + columns: vec![integer_column("id", true)], + ..definition("id") + })); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let counters = harness.fake.connection.counters(); + let tracker = harness.tracker(); + let imp = harness.tab().imp(); + harness.tab().send(StructureTabInput::ColumnEdited { + index: 0, + field: ColumnField::Comment(Some("key".to_owned())), + }); + + let _saving = tracker.begin_save().expect("a save").expect("the change is pending"); + harness.tab().send(StructureTabInput::ColumnEdited { + index: 0, + field: ColumnField::Comment(Some("before the tab heard".to_owned())), + }); + harness.tab().send(StructureTabInput::SaveStarted); + tracker.settle_save(&crate::workspace::SaveOutcome::Committed { rows: None }); + harness.tab().send(StructureTabInput::AddColumn); + harness + .tab() + .send(StructureTabInput::SaveCompleted { new_table_name: None }); + harness.tab().send(StructureTabInput::AddColumn); + + assert_eq!(harness.comments(), [Some("key".to_owned())]); + assert_eq!(harness.column_names(), ["id"]); + assert!(!imp.columns_box.is_sensitive()); + wait_until(WAIT, || { + CallCounters::get(&counters.fetch_table_definition) == 2 && imp.columns_box.is_sensitive() + }) + .expect("the read after the save"); + harness.tab().send(StructureTabInput::AddColumn); + assert_eq!(harness.column_names(), ["id", "column_1"]); } From 208dd39760a88e10c1a38c554504db3316325587 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:22:41 +0700 Subject: [PATCH 143/178] refactor(linux): give save rejections a type and keep services off ui --- .../crates/app/src/services/change_tracker.rs | 11 ++---- .../app/src/services/structure_tracker.rs | 14 +++++--- linux/crates/app/src/ui/app/structure.rs | 2 +- .../ddl_text.rs => error_text/ddl.rs} | 11 +++--- linux/crates/app/src/ui/error_text/mod.rs | 2 ++ linux/crates/app/src/ui/mod.rs | 2 +- .../app/src/ui/structure_tab/columns.rs | 2 +- linux/crates/app/src/ui/structure_tab/mod.rs | 3 +- .../crates/app/src/ui/structure_tab/tests.rs | 8 +++-- .../app/src/workspace/save_rejection.rs | 34 +++++++++++++++++-- linux/po/POTFILES.in | 2 +- 11 files changed, 60 insertions(+), 31 deletions(-) rename linux/crates/app/src/ui/{structure_tab/ddl_text.rs => error_text/ddl.rs} (93%) diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index 98160245a9..b7fe4fdc17 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -9,7 +9,6 @@ use tablepro_core::edit::CellInput; use tablepro_core::meta::RowIdentity; use tablepro_core::save::{ChangeSet, RowChange, RowUpdate}; use tablepro_core::{ColumnInfo, Value}; -use tablepro_session::OperationError; use super::cell_overlay::CellOverlay; use super::save_batch::{SaveBatch, SaveTarget}; @@ -606,15 +605,9 @@ impl TabChangeTracker { return Ok(None); } let Some(target) = self.target.clone() else { - return Err(SaveRejection::Invalid(crate::i18n::gettext( - "The table's columns have not been read yet. Try again in a moment.", - ))); + return Err(SaveRejection::ColumnsNotRead); }; - let changes = self.change_set(&target.columns).map_err(|error| { - SaveRejection::Invalid(crate::ui::error_text::operation_error_message(&OperationError::Build( - error, - ))) - })?; + let changes = self.change_set(&target.columns).map_err(SaveRejection::Build)?; if changes.changes.is_empty() { return Ok(None); } diff --git a/linux/crates/app/src/services/structure_tracker.rs b/linux/crates/app/src/services/structure_tracker.rs index 9ea1b9714a..19b6cf834f 100644 --- a/linux/crates/app/src/services/structure_tracker.rs +++ b/linux/crates/app/src/services/structure_tracker.rs @@ -54,9 +54,7 @@ impl StructureChangeTracker { let Some(pending) = self.pending.clone().filter(PendingStructure::has_changes) else { return Ok(None); }; - pending - .check() - .map_err(|blocker| SaveRejection::Invalid(crate::ui::structure_tab::save_blocker_text(&blocker)))?; + pending.check().map_err(SaveRejection::Blocked)?; self.saving = true; Ok(Some(pending)) } @@ -82,10 +80,11 @@ impl StructureChangeTracker { #[cfg(test)] mod tests { - use tablepro_core::ddl::{DraftColumn, DraftType, TableChanges, TableDraft}; + use tablepro_core::ddl::{DraftColumn, DraftProblem, DraftType, TableChanges, TableDraft}; use tablepro_core::meta::TableRef; use super::*; + use crate::services::pending_structure::SaveBlocker; fn new_table(columns: usize) -> PendingStructure { let mut draft = TableDraft::new(TableRef::unqualified("t")); @@ -148,7 +147,12 @@ mod tests { unnamed.draft.columns[0].name = String::new(); tracker.set_pending(Some(unnamed)); - assert!(matches!(tracker.begin_save(), Err(SaveRejection::Invalid(_)))); + assert_eq!( + tracker.begin_save(), + Err(SaveRejection::Blocked(SaveBlocker::Draft( + DraftProblem::EmptyColumnName + ))) + ); assert_eq!(tracker.pending_state(), PendingState::Unsaved); } } diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index 141c876804..29dc5d06e9 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -270,7 +270,7 @@ impl App { Ok(None) => crate::i18n::gettext_f("Table {table} has no columns.", &[("table", &table)]), Err(error) => crate::i18n::gettext_f( "Couldn't build SQL: {error}", - &[("error", &crate::ui::structure_tab::build_error_text(&error))], + &[("error", &error_text::build_error_text(&error))], ), }, Err(error) => crate::i18n::gettext_f( diff --git a/linux/crates/app/src/ui/structure_tab/ddl_text.rs b/linux/crates/app/src/ui/error_text/ddl.rs similarity index 93% rename from linux/crates/app/src/ui/structure_tab/ddl_text.rs rename to linux/crates/app/src/ui/error_text/ddl.rs index 3672f92bbe..c2b1ef4b8f 100644 --- a/linux/crates/app/src/ui/structure_tab/ddl_text.rs +++ b/linux/crates/app/src/ui/error_text/ddl.rs @@ -1,11 +1,12 @@ -//! The structure tab's DDL refusals and problems in the user's words. +//! DDL refusals and problems in the user's words, for the Structure tab +//! and for a save refused before it starts. use tablepro_core::ddl::{BuildDdlError, DdlOperation, DraftProblem, RefusalReason}; use crate::services::pending_structure::SaveBlocker; /// Why a control is off, for its tooltip. -pub(crate) fn refusal_text(reason: &RefusalReason) -> String { +pub fn refusal_text(reason: &RefusalReason) -> String { match reason { RefusalReason::EngineUnsupported => crate::i18n::gettext("This database can't make this change."), RefusalReason::KeyColumn => crate::i18n::gettext( @@ -33,7 +34,7 @@ pub(crate) fn refusal_text(reason: &RefusalReason) -> String { } /// The first thing to fix before Save. -pub(crate) fn draft_problem_text(problem: &DraftProblem) -> String { +fn draft_problem_text(problem: &DraftProblem) -> String { match problem { DraftProblem::EmptyTableName => crate::i18n::gettext("Table name is required."), DraftProblem::NoColumns => crate::i18n::gettext("At least one column is required."), @@ -62,7 +63,7 @@ pub(crate) fn draft_problem_text(problem: &DraftProblem) -> String { } /// Why Save, from the tab or from a close prompt, refused. -pub(crate) fn save_blocker_text(blocker: &SaveBlocker) -> String { +pub fn save_blocker_text(blocker: &SaveBlocker) -> String { match blocker { SaveBlocker::Draft(problem) => draft_problem_text(problem), SaveBlocker::UnparsedDefault(_) => crate::i18n::gettext("Fix the highlighted fields first."), @@ -70,7 +71,7 @@ pub(crate) fn save_blocker_text(blocker: &SaveBlocker) -> String { } /// Why the change could not be written as SQL. -pub(crate) fn build_error_text(error: &BuildDdlError) -> String { +pub fn build_error_text(error: &BuildDdlError) -> String { match error { BuildDdlError::Draft(problem) => draft_problem_text(problem), BuildDdlError::Refused { column, reason, .. } => crate::i18n::gettext_f( diff --git a/linux/crates/app/src/ui/error_text/mod.rs b/linux/crates/app/src/ui/error_text/mod.rs index 320c4a8d7d..4906757306 100644 --- a/linux/crates/app/src/ui/error_text/mod.rs +++ b/linux/crates/app/src/ui/error_text/mod.rs @@ -5,6 +5,7 @@ //! adds later still lands in a branch that says what to do next; the //! variant only adds detail. +mod ddl; mod driver; mod operation; mod script; @@ -15,6 +16,7 @@ mod tests; mod tls; mod tls_remedy; +pub use ddl::{build_error_text, refusal_text, save_blocker_text}; pub use driver::{config_message, driver_message}; pub use operation::{connect_error_message, operation_error_message, task_failure_message}; pub use script::script_diagnostic_message; diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 82e10e8b6c..93d077658f 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -21,7 +21,7 @@ mod saved_queries_dialog; mod sidebar_row; mod ssh_inputs; mod ssh_section; -pub(crate) mod structure_tab; +mod structure_tab; mod structure_tab_dialogs; mod style_scheme_grid; mod style_scheme_resolver; diff --git a/linux/crates/app/src/ui/structure_tab/columns.rs b/linux/crates/app/src/ui/structure_tab/columns.rs index 3b453eaabd..6e87b2caa2 100644 --- a/linux/crates/app/src/ui/structure_tab/columns.rs +++ b/linux/crates/app/src/ui/structure_tab/columns.rs @@ -11,8 +11,8 @@ use libadwaita::prelude::*; use tablepro_core::ddl::{ColumnCapabilities, DraftColumn, DraftType, Permission}; use tablepro_core::dialect::SqlDialect; -use super::ddl_text::refusal_text; use super::{ColumnField, StructureTabInput, StructureTabSender}; +use crate::ui::error_text::refusal_text; /// What a column row needs beyond the column: which changes the engine /// allows on it, and the dialect that checks what the user types. diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index a10124301f..4e92876d3b 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -31,7 +31,6 @@ //! failed. mod columns; -mod ddl_text; mod fks; mod imp; mod indexes; @@ -62,13 +61,13 @@ use tablepro_session::runtime::{Tasks, WatchForwarder}; use tablepro_session::{LinkGeneration, OperationError, OperationKind, Session}; use self::columns::{ColumnRowContext, build_column_expander_row}; -pub(crate) use self::ddl_text::{build_error_text, save_blocker_text}; use self::fks::build_fk_row; use self::indexes::build_index_row; pub(crate) use self::structure_command::{LoadedStructure, StructureCommand, StructureRequestKind}; use crate::runtime::{FailedRequests, RequestSequencer, RequestTicket, Stamped}; use crate::services::pending_structure::PendingStructure; use crate::services::structure_tracker_handle::StructureTrackerHandle; +use crate::ui::error_text::{build_error_text, save_blocker_text}; use crate::ui::handler_guard::HandlerGuard; use crate::ui::structure_tab_dialogs::{present_fk_dialog, present_index_dialog}; diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index 16917c7cc3..7bb167d8bf 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -545,10 +545,12 @@ fn an_edit_previews_the_plan_and_saves_through_the_tracker() { "a default that does not parse can be saved" ); // What a close prompt's Save reads. - assert!(matches!( + assert_eq!( tracker.begin_save(), - Err(crate::workspace::SaveRejection::Invalid(_)) - )); + Err(crate::workspace::SaveRejection::Blocked( + crate::services::pending_structure::SaveBlocker::UnparsedDefault("id".to_owned()) + )) + ); drop(tab); drain_main_context(); runtime.shutdown(); diff --git a/linux/crates/app/src/workspace/save_rejection.rs b/linux/crates/app/src/workspace/save_rejection.rs index eb514c7339..d542d20353 100644 --- a/linux/crates/app/src/workspace/save_rejection.rs +++ b/linux/crates/app/src/workspace/save_rejection.rs @@ -1,3 +1,8 @@ +use tablepro_core::dml::BuildSqlError; +use tablepro_session::OperationError; + +use crate::services::pending_structure::SaveBlocker; + /// Why a save did not start. Nothing was sent. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SaveRejection { @@ -6,8 +11,13 @@ pub enum SaveRejection { OutcomeUnknown, AlreadySaving, ReadOnly, - /// The changes cannot be written as they stand. - Invalid(String), + /// The tab has not read the table's columns, which a row save is + /// planned against. + ColumnsNotRead, + /// The row changes cannot be written as SQL. + Build(BuildSqlError), + /// The structure cannot be saved as it stands. + Blocked(SaveBlocker), NoConnection, } @@ -20,8 +30,26 @@ impl SaveRejection { ), Self::AlreadySaving => crate::i18n::gettext("These changes are already being saved."), Self::ReadOnly => crate::i18n::gettext("This connection is read-only."), - Self::Invalid(reason) => reason.clone(), + Self::ColumnsNotRead => { + crate::i18n::gettext("The table's columns have not been read yet. Try again in a moment.") + } + Self::Build(error) => crate::ui::error_text::operation_error_message(&OperationError::Build(error.clone())), + Self::Blocked(blocker) => crate::ui::error_text::save_blocker_text(blocker), Self::NoConnection => crate::i18n::gettext("No active connection."), } } } + +#[cfg(test)] +mod tests { + use tablepro_core::ddl::DraftProblem; + + use super::*; + + #[test] + fn a_blocked_structure_says_what_to_fix() { + let rejection = SaveRejection::Blocked(SaveBlocker::Draft(DraftProblem::NoColumns)); + + assert_eq!(rejection.message(), "At least one column is required."); + } +} diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 5b4a49670d..f933e7b699 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -121,6 +121,7 @@ crates/app/src/ui/editor/statement_cursor.rs crates/app/src/ui/editor/tests.rs crates/app/src/ui/editor/transaction_dirty.rs crates/app/src/ui/editor_font_provider.rs +crates/app/src/ui/error_text/ddl.rs crates/app/src/ui/error_text/driver.rs crates/app/src/ui/error_text/mod.rs crates/app/src/ui/error_text/operation.rs @@ -162,7 +163,6 @@ crates/app/src/ui/sidebar_row.rs crates/app/src/ui/ssh_inputs.rs crates/app/src/ui/ssh_section.rs crates/app/src/ui/structure_tab/columns.rs -crates/app/src/ui/structure_tab/ddl_text.rs crates/app/src/ui/structure_tab/fks.rs crates/app/src/ui/structure_tab/imp.rs crates/app/src/ui/structure_tab/indexes.rs From 87e63e4f2f2bd07d5cb57213ca1368bdbf4dd976 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:23:37 +0700 Subject: [PATCH 144/178] fix(linux): say why an edit waits on a save; ignore a second Save --- .../crates/app/src/services/change_tracker.rs | 6 ++-- .../app/src/services/tab_tracker_handle.rs | 14 +++++++-- linux/crates/app/src/services/tracker_edit.rs | 11 +++++++ linux/crates/app/src/ui/app/saves.rs | 4 +-- linux/crates/app/src/ui/browse_tab/mod.rs | 7 +++++ linux/crates/app/src/ui/browse_tab/tests.rs | 31 +++++++++++++++++++ .../app/src/workspace/save_rejection.rs | 20 ++++++++++++ 7 files changed, 87 insertions(+), 6 deletions(-) diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index b7fe4fdc17..645ecccac3 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -133,9 +133,11 @@ impl TabChangeTracker { self.drafts.len() + self.deletes.len() + self.updates.len() } + /// An edit the running save holds back comes back as a rebind of its + /// row, so the grid stops showing what was typed into it. pub fn apply(&mut self, edit: TrackerEdit) -> TrackerDelta { if self.edit_is_in_flight(&edit) { - return self.delta(Vec::new(), Vec::new()); + return self.delta(edit.row_key().into_iter().collect(), Vec::new()); } match edit { TrackerEdit::Cell { @@ -158,7 +160,7 @@ impl TabChangeTracker { /// An insert or a delete cannot be sent twice, so a draft or a /// delete in the save stays as it is until the save settles; a cell /// edit is a new value, which the next save writes. - fn edit_is_in_flight(&self, edit: &TrackerEdit) -> bool { + pub fn edit_is_in_flight(&self, edit: &TrackerEdit) -> bool { let Some(snapshot) = self.saving else { return false; }; diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs index 330521b082..862dcde560 100644 --- a/linux/crates/app/src/services/tab_tracker_handle.rs +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -31,6 +31,11 @@ impl TabTrackerHandle { self.0.borrow_mut().apply(edit) } + /// Whether `apply` would hold the edit back for the running save. + pub fn refuses(&self, edit: &TrackerEdit) -> bool { + self.0.borrow().edit_is_in_flight(edit) + } + #[must_use = "the grid shows stale rows until the delta is applied"] pub fn undo(&self) -> Option { self.0.borrow_mut().undo() @@ -209,14 +214,19 @@ mod tests { }; let batch = tracker.begin_save().expect("a save").expect("changes to save"); - let _ = tracker.apply(TrackerEdit::DraftCell { + let typed = TrackerEdit::DraftCell { draft_id, col: 1, value: Value::Int(10), - }); + }; + assert!(tracker.refuses(&typed)); + let held = tracker.apply(typed); let _ = tracker.apply(TrackerEdit::DiscardDraft { draft_id }); assert!(tracker.undo().is_none(), "undo ran while a save was in flight"); + assert_eq!(held.rows, [RowKey::Draft(draft_id)], "the typed value stayed on screen"); + assert!(held.visual.is_empty()); + assert_eq!(tracker.drafts()[0].values[1], Value::Int(9)); let _ = tracker.settle_save(&rolled_back(), batch.snapshot); assert!(tracker.undo().is_some()); diff --git a/linux/crates/app/src/services/tracker_edit.rs b/linux/crates/app/src/services/tracker_edit.rs index d8eca5ddd1..4de95bd657 100644 --- a/linux/crates/app/src/services/tracker_edit.rs +++ b/linux/crates/app/src/services/tracker_edit.rs @@ -27,3 +27,14 @@ pub enum TrackerEdit { draft_id: u64, }, } + +impl TrackerEdit { + /// The row the edit changes. An insert makes a new one. + pub fn row_key(&self) -> Option { + match self { + Self::Cell { row_key, .. } | Self::Delete { row_key, .. } => Some(row_key.clone()), + Self::DraftCell { draft_id, .. } | Self::DiscardDraft { draft_id } => Some(RowKey::Draft(*draft_id)), + Self::Insert { .. } => None, + } + } +} diff --git a/linux/crates/app/src/ui/app/saves.rs b/linux/crates/app/src/ui/app/saves.rs index a67f2718dd..4daf5b8bef 100644 --- a/linux/crates/app/src/ui/app/saves.rs +++ b/linux/crates/app/src/ui/app/saves.rs @@ -14,10 +14,10 @@ impl App { for start in self.workspace_handle.save(&[tab]) { match start { SaveStart::Started(save) => tracing::debug!(?save, %tab, "save started"), - SaveStart::NothingToSave => {} - SaveStart::Rejected(rejection) => { + SaveStart::Rejected(rejection) if rejection.needs_alert() => { self.show_error_alert(&crate::i18n::gettext("Cannot Save"), &rejection.message()); } + SaveStart::NothingToSave | SaveStart::Rejected(_) => {} } } } diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 95c50b8dc5..edcfe09fbf 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -512,6 +512,8 @@ impl From for BrowseTabInput { pub(crate) struct Effects { outputs: Vec, inputs: VecDeque, + /// A bulk delete over rows in the running save says so once. + told_edit_held: bool, } impl Effects { @@ -869,6 +871,11 @@ impl BrowseModel { } fn apply_edit(&self, edit: TrackerEdit, fx: &mut Effects) { + if self.tracker.refuses(&edit) && !std::mem::replace(&mut fx.told_edit_held, true) { + fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( + "This row is being saved. Change it again once the save finishes.", + ))); + } let delta = self.tracker.apply(edit); self.apply_tracker_delta(delta, fx); } diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 759ed6ec1f..d93828cceb 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -793,6 +793,37 @@ fn stop_saving_button_visible_only_while_saving() { assert!(save.is_sensitive()); } +#[gtk4::test] +fn an_edit_held_for_the_running_save_says_so_once() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let tracker = harness + .tab() + .imp() + .model + .borrow() + .as_ref() + .map(|model| model.tracker.clone()) + .expect("the tab has a model"); + let toasts = toasts(harness.tab()); + harness.tab().send(BrowseTabInput::InsertRow); + harness.tab().send(BrowseTabInput::InsertRow); + let _saving = tracker.begin_save().expect("a save").expect("the drafts"); + let drafts: Vec = tracker.drafts().iter().map(|draft| draft.draft_id).collect(); + + harness.tab().send(BrowseTabInput::MarkRowsDeleted { + rows: Vec::new(), + drafts, + }); + + assert_eq!(tracker.drafts().len(), 2, "a draft the save holds was dropped"); + assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); +} + #[gtk4::test] fn browse_tab_and_filter_strip_freed_after_drop() { let mut script = serving_table(); diff --git a/linux/crates/app/src/workspace/save_rejection.rs b/linux/crates/app/src/workspace/save_rejection.rs index d542d20353..3c03c79620 100644 --- a/linux/crates/app/src/workspace/save_rejection.rs +++ b/linux/crates/app/src/workspace/save_rejection.rs @@ -38,6 +38,12 @@ impl SaveRejection { Self::NoConnection => crate::i18n::gettext("No active connection."), } } + + /// A Save pressed again while the first runs needs no alert: the tab + /// already shows the save running. + pub fn needs_alert(&self) -> bool { + !matches!(self, Self::AlreadySaving) + } } #[cfg(test)] @@ -46,6 +52,20 @@ mod tests { use super::*; + #[test] + fn only_a_save_already_running_goes_untold() { + let told = [ + SaveRejection::OutcomeUnknown, + SaveRejection::ReadOnly, + SaveRejection::ColumnsNotRead, + SaveRejection::Blocked(SaveBlocker::Draft(DraftProblem::NoColumns)), + SaveRejection::NoConnection, + ]; + + assert!(told.iter().all(SaveRejection::needs_alert)); + assert!(!SaveRejection::AlreadySaving.needs_alert()); + } + #[test] fn a_blocked_structure_says_what_to_fix() { let rejection = SaveRejection::Blocked(SaveBlocker::Draft(DraftProblem::NoColumns)); From 5291d52f36de5aba562b66f63623546941672651 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:23:49 +0700 Subject: [PATCH 145/178] fix(linux): find MariaDB column checks by line and refuse a large SRID --- linux/crates/drivers/mysql/src/catalog.rs | 5 +- .../mysql/src/catalog/table_definition.rs | 177 ++++++++++++++++-- linux/crates/drivers/mysql/src/metadata.rs | 36 ++-- 3 files changed, 191 insertions(+), 27 deletions(-) diff --git a/linux/crates/drivers/mysql/src/catalog.rs b/linux/crates/drivers/mysql/src/catalog.rs index 3309cdda09..5f15bdc3b2 100644 --- a/linux/crates/drivers/mysql/src/catalog.rs +++ b/linux/crates/drivers/mysql/src/catalog.rs @@ -13,8 +13,9 @@ pub(crate) use catalog_cells::CatalogCells; pub(crate) use mysql_column_row::{MySqlColumnRow, columns_sql}; pub(crate) use mysql_extra::{ExtraGenerated, MySqlExtra}; pub(crate) use table_definition::{ - COLUMN_ENGINE_ATTRIBUTES_SQL, INBOUND_FOREIGN_KEYS_SQL, MARIADB_COLUMN_CHECKS_SQL, MARIADB_GEOMETRY_SRIDS_SQL, - column_checks, geometry_srids, inbound_foreign_keys, + COLUMN_ENGINE_ATTRIBUTES_SQL, ColumnCheck, INBOUND_FOREIGN_KEYS_SQL, ListedSrid, MARIADB_COLUMN_CHECKS_SQL, + MARIADB_GEOMETRY_SRIDS_SQL, column_checks, geometry_srids, inbound_foreign_keys, place_column_checks, + printed_names, }; use sqlx::mysql::{MySql, MySqlColumn}; diff --git a/linux/crates/drivers/mysql/src/catalog/table_definition.rs b/linux/crates/drivers/mysql/src/catalog/table_definition.rs index 93fa4f257a..1d5c0e8dce 100644 --- a/linux/crates/drivers/mysql/src/catalog/table_definition.rs +++ b/linux/crates/drivers/mysql/src/catalog/table_definition.rs @@ -27,34 +27,109 @@ SELECT G_GEOMETRY_COLUMN, SRID FROM information_schema.GEOMETRY_COLUMNS WHERE F_TABLE_SCHEMA = COALESCE(?, DATABASE()) AND F_TABLE_NAME = ?"; -/// MariaDB's CHECK constraints written on a column, each named after its -/// column. A MODIFY that does not restate one drops it, the `json_valid` -/// check MariaDB puts on every JSON column included. +/// MariaDB's CHECK constraints written on a column. A MODIFY that does +/// not restate one drops it, the `json_valid` check MariaDB puts on every +/// JSON column included. The constraint keeps the column's name from when +/// it was written through a rename, so it does not tell the column. pub(crate) const MARIADB_COLUMN_CHECKS_SQL: &str = "\ -SELECT CONSTRAINT_NAME, CHECK_CLAUSE +SELECT CHECK_CLAUSE FROM information_schema.CHECK_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ? AND LEVEL = 'Column'"; +/// GEOMETRY_COLUMNS holds the SRID in a smallint, so a larger one reads +/// as this. +const SATURATED_SRID: u64 = 32767; + +/// A geometry column's SRID as MariaDB lists it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ListedSrid { + Exact(u64), + /// 32767 or above, which the catalogue cannot tell apart. + Saturated, +} + /// Each geometry column that sets an SRID. MariaDB lists 0 for one that /// sets none. -pub(crate) fn geometry_srids(rows: &[CatalogCells]) -> Result, DriverError> { +pub(crate) fn geometry_srids(rows: &[CatalogCells]) -> Result, DriverError> { let mut srids = Vec::new(); for row in rows { let column = row.required_text(0)?; - if let Some(srid) = row.number(1)?.filter(|srid| *srid != 0) { - srids.push((column, srid)); + match row.number(1)? { + None | Some(0) => {} + Some(SATURATED_SRID) => srids.push((column, ListedSrid::Saturated)), + Some(srid) => srids.push((column, ListedSrid::Exact(srid))), } } Ok(srids) } -/// Each column's own CHECK clause, as SQL. -pub(crate) fn column_checks(rows: &[CatalogCells]) -> Result, DriverError> { - rows.iter() - .map(|row| Ok((row.required_text(0)?, row.required_text(1)?))) +/// Each column-level CHECK clause, as SQL. +pub(crate) fn column_checks(rows: &[CatalogCells]) -> Result, DriverError> { + rows.iter().map(|row| row.required_text(0)).collect() +} + +/// What a column's own CHECK is, as far as the catalogue tells. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ColumnCheck { + None, + Clause(String), + /// The table has a column-level CHECK no column's line ends with, so + /// any column without one may be its owner. + Unplaced, +} + +/// Gives each column-level clause to the column whose line in SHOW +/// CREATE TABLE ends with it. MariaDB prints a column's CHECK last on +/// the column's line, and its expression and the catalogue's clause are +/// the same text, which follows a rename as the constraint name does not. +pub(crate) fn place_column_checks(create_statement: &str, columns: &[&str], clauses: Vec) -> Vec { + let mut unplaced = clauses; + let placed: Vec> = columns + .iter() + .map(|column| { + let line = column_line(create_statement, column)?; + let position = unplaced.iter().position(|clause| ends_with_check(line, clause))?; + Some(unplaced.swap_remove(position)) + }) + .collect(); + placed + .into_iter() + .map(|clause| match clause { + Some(clause) => ColumnCheck::Clause(clause), + None if unplaced.is_empty() => ColumnCheck::None, + None => ColumnCheck::Unplaced, + }) .collect() } +/// A name as the server prints it in SHOW CREATE TABLE and in the +/// catalogue's expressions: in backticks, or in double quotes when the +/// session's sql_mode has ANSI_QUOTES. +pub(crate) fn printed_names(name: &str) -> [String; 2] { + ['`', '"'].map(|quote| { + let doubled = name.replace(quote, &format!("{quote}{quote}")); + format!("{quote}{doubled}{quote}") + }) +} + +/// SHOW CREATE TABLE prints each column on a line of its own, two spaces +/// in, and escapes a line break inside a string or comment. +fn column_line<'a>(create_statement: &'a str, column: &str) -> Option<&'a str> { + printed_names(column).iter().find_map(|printed| { + let head = format!("\n {printed} "); + let start = create_statement.find(&head)? + 3; + let rest = create_statement.get(start..)?; + let line = rest.split('\n').next()?; + Some(line.strip_suffix(',').unwrap_or(line)) + }) +} + +fn ends_with_check(line: &str, clause: &str) -> bool { + line.strip_suffix(')') + .and_then(|rest| rest.strip_suffix(clause)) + .is_some_and(|rest| rest.ends_with(" CHECK (")) +} + /// One key per constraint, its columns in order. pub(crate) fn inbound_foreign_keys(rows: &[CatalogCells]) -> Result, DriverError> { let mut keys: Vec = Vec::new(); @@ -106,7 +181,85 @@ mod tests { CatalogCells::new("GEOMETRY_COLUMNS", vec![Value::Text("area".to_owned()), Value::Int(0)]), ]; - assert_eq!(geometry_srids(&rows).expect("SRIDs"), [("shape".to_owned(), 4326)]); + assert_eq!( + geometry_srids(&rows).expect("SRIDs"), + [("shape".to_owned(), ListedSrid::Exact(4326))] + ); + } + + #[test] + fn an_srid_the_smallint_cannot_hold_is_not_taken_as_32767() { + let rows = [CatalogCells::new( + "GEOMETRY_COLUMNS", + vec![Value::Text("web".to_owned()), Value::Int(32767)], + )]; + + assert_eq!( + geometry_srids(&rows).expect("SRIDs"), + [("web".to_owned(), ListedSrid::Saturated)] + ); + } + + const RENAMED: &str = "CREATE TABLE `t` (\n `id` int(11) NOT NULL,\n `body` longtext CHARACTER SET utf8mb4 \ + COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`body`)),\n `b` int(11) DEFAULT NULL \ + CHECK (`b` > 0),\n `doc` int(11) DEFAULT NULL COMMENT 'x CHECK (`b` > 0)',\n PRIMARY \ + KEY (`id`),\n CONSTRAINT `tc` CHECK (`b` < 100)\n) ENGINE=InnoDB"; + + #[test] + fn a_check_goes_to_the_column_whose_line_ends_with_it() { + let placed = place_column_checks( + RENAMED, + &["id", "body", "b", "doc"], + vec!["json_valid(`body`)".to_owned(), "`b` > 0".to_owned()], + ); + + assert_eq!( + placed, + [ + ColumnCheck::None, + ColumnCheck::Clause("json_valid(`body`)".to_owned()), + ColumnCheck::Clause("`b` > 0".to_owned()), + ColumnCheck::None, + ] + ); + } + + #[test] + fn a_check_read_under_ansi_quotes_still_finds_its_column() { + let ansi = RENAMED.replace('`', "\""); + + let placed = place_column_checks(&ansi, &["id", "body"], vec!["json_valid(\"body\")".to_owned()]); + + assert_eq!( + placed, + [ + ColumnCheck::None, + ColumnCheck::Clause("json_valid(\"body\")".to_owned()) + ] + ); + } + + #[test] + fn a_check_no_line_ends_with_leaves_every_column_without_one_unplaced() { + let placed = place_column_checks( + RENAMED, + &["id", "body", "b"], + vec!["json_valid(`body`)".to_owned(), "`gone` > 1".to_owned()], + ); + + assert_eq!( + placed, + [ + ColumnCheck::Unplaced, + ColumnCheck::Clause("json_valid(`body`)".to_owned()), + ColumnCheck::Unplaced, + ] + ); + } + + #[test] + fn a_printed_name_doubles_its_quote() { + assert_eq!(printed_names("a`b\"c"), ["`a``b\"c`", "\"a`b\"\"c\""]); } #[test] diff --git a/linux/crates/drivers/mysql/src/metadata.rs b/linux/crates/drivers/mysql/src/metadata.rs index 73b7dc4ed9..e4e37b884a 100644 --- a/linux/crates/drivers/mysql/src/metadata.rs +++ b/linux/crates/drivers/mysql/src/metadata.rs @@ -17,9 +17,9 @@ use tablepro_core::meta::{ use tablepro_core::{DriverError, ForeignKeyInfo}; use crate::catalog::{ - COLUMN_ENGINE_ATTRIBUTES_SQL, CatalogCells, INBOUND_FOREIGN_KEYS_SQL, MARIADB_COLUMN_CHECKS_SQL, - MARIADB_GEOMETRY_SRIDS_SQL, MySqlColumnRow, column_checks, column_definition, column_info, columns_sql, - geometry_srids, inbound_foreign_keys, + COLUMN_ENGINE_ATTRIBUTES_SQL, CatalogCells, ColumnCheck, INBOUND_FOREIGN_KEYS_SQL, ListedSrid, + MARIADB_COLUMN_CHECKS_SQL, MARIADB_GEOMETRY_SRIDS_SQL, MySqlColumnRow, column_checks, column_definition, + column_info, columns_sql, geometry_srids, inbound_foreign_keys, place_column_checks, }; use crate::decode::decode_row; use crate::dialect::MySqlDialect; @@ -320,7 +320,8 @@ pub(crate) async fn fetch_table_definition( let flavour = dialect.flavour(); let mut rows = column_rows(connection, flavour, table).await?; let engine = table_engine::lookup(connection, flavour, table).await?; - let attributes = engine_attributes(connection, dialect, table).await; + let mut attributes = engine_attributes(connection, dialect, table).await; + let create_statement = show_create(connection, dialect, table).await?; let checks = match flavour { MySqlFlavour::MariaDb => { let binds = table_binds(table); @@ -333,8 +334,13 @@ pub(crate) async fn fetch_table_definition( ) .await?; for (column, srid) in geometry_srids(&geometry)? { - if let Some(row) = rows.iter_mut().find(|row| row.name == column) { - row.srs_id = Some(srid); + match srid { + ListedSrid::Exact(srid) => { + if let Some(row) = rows.iter_mut().find(|row| row.name == column) { + row.srs_id = Some(srid); + } + } + ListedSrid::Saturated => attributes.push((column, "REF_SYSTEM_ID".to_owned())), } } let checks = catalog_rows( @@ -345,13 +351,15 @@ pub(crate) async fn fetch_table_definition( &binds, ) .await?; - column_checks(&checks)? + let names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect(); + place_column_checks(&create_statement, &names, column_checks(&checks)?) } MySqlFlavour::MySql => Vec::new(), }; let columns = rows .iter() - .map(|row| { + .enumerate() + .map(|(index, row)| { let mut unmodeled: Vec = attributes .iter() .filter(|(column, _)| *column == row.name) @@ -360,11 +368,14 @@ pub(crate) async fn fetch_table_definition( if engine.is_ndb() { unmodeled.push(UnmodeledAttribute::new("ENGINE=ndbcluster")); } + let check = checks.get(index); + if check == Some(&ColumnCheck::Unplaced) { + unmodeled.push(UnmodeledAttribute::new("CHECK")); + } let mut definition = column_definition(row, flavour, unmodeled); - definition.attributes.check = checks - .iter() - .find(|(column, _)| *column == row.name) - .map(|(_, clause)| SqlExpression::from_catalog_text(clause.as_str())); + if let Some(ColumnCheck::Clause(clause)) = check { + definition.attributes.check = Some(SqlExpression::from_catalog_text(clause.as_str())); + } definition }) .collect(); @@ -386,7 +397,6 @@ pub(crate) async fn fetch_table_definition( ) .await?; let inbound_foreign_keys: Vec = inbound_foreign_keys(&inbound)?; - let create_statement = show_create(connection, dialect, table).await?; Ok(TableDefinition { table: table.clone(), columns, From 844cb784ec27122a95c8ac69da99b58da0ef541a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:23:56 +0700 Subject: [PATCH 146/178] fix(linux): refuse a MySQL restatement naming a column it renames --- .../app/src/ui/structure_tab/ddl_text.rs | 8 ++ linux/crates/core/src/ddl/refusal_reason.rs | 9 ++ linux/crates/drivers/mysql/src/ddl.rs | 104 +++++++++++++++++- .../drivers/mysql/tests/integration/ddl.rs | 82 +++++++++++++- 4 files changed, 200 insertions(+), 3 deletions(-) diff --git a/linux/crates/app/src/ui/structure_tab/ddl_text.rs b/linux/crates/app/src/ui/structure_tab/ddl_text.rs index 3672f92bbe..73076151c4 100644 --- a/linux/crates/app/src/ui/structure_tab/ddl_text.rs +++ b/linux/crates/app/src/ui/structure_tab/ddl_text.rs @@ -29,6 +29,14 @@ pub(crate) fn refusal_text(reason: &RefusalReason) -> String { RefusalReason::UnreadableDefinition => { crate::i18n::gettext("The database didn't report this column's full definition.") } + RefusalReason::ConflictingAttribute(attribute) => crate::i18n::gettext_f( + "This column's {attribute} doesn't allow this change.", + &[("attribute", attribute)], + ), + RefusalReason::NamesRenamedColumn(column) => crate::i18n::gettext_f( + "Its definition names column {column}, which this save renames. Save the rename on its own first.", + &[("column", column)], + ), } } diff --git a/linux/crates/core/src/ddl/refusal_reason.rs b/linux/crates/core/src/ddl/refusal_reason.rs index 0fe1ca8bb5..d105cf99cd 100644 --- a/linux/crates/core/src/ddl/refusal_reason.rs +++ b/linux/crates/core/src/ddl/refusal_reason.rs @@ -26,6 +26,13 @@ pub enum RefusalReason { ReferencedByForeignKeys(Vec), /// The catalogue gave no definition the change could restate. UnreadableDefinition, + /// The column has an attribute the change would break, such as + /// SPARSE on a SQL Server column made NOT NULL. + ConflictingAttribute(String), + /// The restated definition names a column the same change renames. + /// The server reads it against the new names, and the catalogue's + /// text still has the old one. + NamesRenamedColumn(String), } impl fmt::Display for RefusalReason { @@ -39,6 +46,8 @@ impl fmt::Display for RefusalReason { Self::ColumnInUse(users) => write!(f, "still used by {}", users.join(", ")), Self::ReferencedByForeignKeys(keys) => write!(f, "referenced by foreign keys {}", keys.join(", ")), Self::UnreadableDefinition => f.write_str("the catalogue gave no definition to restate"), + Self::ConflictingAttribute(name) => write!(f, "the column's {name} does not allow it"), + Self::NamesRenamedColumn(name) => write!(f, "its definition names {name}, which the same change renames"), } } } diff --git a/linux/crates/drivers/mysql/src/ddl.rs b/linux/crates/drivers/mysql/src/ddl.rs index 09c29188fc..188478ce45 100644 --- a/linux/crates/drivers/mysql/src/ddl.rs +++ b/linux/crates/drivers/mysql/src/ddl.rs @@ -1,13 +1,16 @@ mod mysql_column_definition; +use tablepro_core::column::ColumnDefault; use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, foreign_key_sql, index_keys_sql}; use tablepro_core::ddl::{ - BuildDdlError, ChangedAttributes, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlPlan, - Permission, ReferentialAction, RefusalReason, TableChanges, TableDefinition, TableDraft, + BuildDdlError, ChangedAttributes, ColumnAlteration, ColumnCapabilities, ColumnDefinition, DdlCapabilities, + DdlDialect, DdlOperation, DdlPlan, Permission, ReferentialAction, RefusalReason, TableChanges, TableDefinition, + TableDraft, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; +use crate::catalog::printed_names; use crate::dialect::MySqlDialect; use mysql_column_definition::{nullability_permission, render, restatement}; @@ -153,6 +156,13 @@ impl DdlDialect for MySqlDialect { fn plan_alter_table(&self, original: &TableDefinition, changes: &TableChanges) -> Result { changes.check(self, original)?; + for alteration in changes + .altered_columns + .iter() + .filter(|alteration| restates(alteration.changed)) + { + refuse_renamed_names(alteration, changes)?; + } let mut statements = Vec::new(); let table = changes.target(&original.table); // The new name is qualified too: a bare one resolves against the @@ -259,6 +269,44 @@ fn restates(changed: ChangedAttributes) -> bool { changed.column_type || changed.nullability || changed.default || changed.auto_increment || changed.comment } +/// The server reads a restated column against the names the same ALTER +/// gives, while the expressions it splices were printed with the names +/// before it. MariaDB's own CHECK on a column names the column, and any +/// other expression is searched for a renamed column's old name. +fn refuse_renamed_names(alteration: &ColumnAlteration, changes: &TableChanges) -> Result<(), BuildDdlError> { + let original = &alteration.original; + let attributes = &original.attributes; + let refuse = |renamed: &str| BuildDdlError::Refused { + column: original.name().to_owned(), + operation: DdlOperation::RenameColumn, + reason: RefusalReason::NamesRenamedColumn(renamed.to_owned()), + }; + if attributes.check.is_some() && original.name() != alteration.draft.name { + return Err(refuse(original.name())); + } + let default = match &alteration.draft.default { + ColumnDefault::Expression(expression) => Some(expression), + _ => None, + }; + let expressions = [ + attributes.check.as_ref(), + attributes.generated.as_ref().map(|generated| &generated.expression), + default, + ]; + let named = |name: &str| { + let printed = printed_names(name); + expressions.iter().flatten().any(|expression| { + printed + .iter() + .any(|printed| expression.as_sql().contains(printed.as_str())) + }) + }; + match changes.renamed_columns.iter().find(|rename| named(&rename.from)) { + Some(rename) => Err(refuse(&rename.from)), + None => Ok(()), + } +} + #[cfg(test)] mod tests { use tablepro_core::ForeignKeyInfo; @@ -809,6 +857,58 @@ mod tests { ); } + #[test] + fn a_restatement_that_names_a_column_the_same_save_renames_is_refused() { + let mut table = users(); + table.columns.extend([ + with_attributes("doc", "longtext", |attributes| { + attributes.check = Some(SqlExpression::from_catalog_text("json_valid(`doc`)")); + }), + with_attributes("base", "int", |_| {}), + with_attributes("bounded", "int", |attributes| { + attributes.check = Some(SqlExpression::from_catalog_text("`bounded` > `base`")); + }), + ]); + generated(&mut table, "total", Some("`base` * 2")); + let names = |name: &str| RefusalReason::NamesRenamedColumn(name.to_owned()); + + let own = alter_on(&mariadb(), &table, |draft| { + edit_column(draft, "doc", |column| { + column.name = "body".to_owned(); + column.comment = Some("x".to_owned()); + }); + }); + let checked = alter_on(&mariadb(), &table, |draft| { + edit_column(draft, "base", |column| column.name = "floor".to_owned()); + edit_column(draft, "bounded", |column| column.nullable = false); + }); + let computed = alter_on(&mariadb(), &table, |draft| { + edit_column(draft, "base", |column| column.name = "floor".to_owned()); + edit_column(draft, "total", |column| column.comment = Some("y".to_owned())); + }); + let apart = alter_on(&mariadb(), &table, |draft| { + edit_column(draft, "doc", |column| column.name = "body".to_owned()); + edit_column(draft, "bounded", |column| column.nullable = false); + }); + + assert!(refused(&own, DdlOperation::RenameColumn, &names("doc")), "{own:?}"); + assert!( + refused(&checked, DdlOperation::RenameColumn, &names("base")), + "{checked:?}" + ); + assert!( + refused(&computed, DdlOperation::RenameColumn, &names("base")), + "{computed:?}" + ); + assert_eq!( + sql(apart), + [ + "ALTER TABLE `app`.`users` RENAME COLUMN `doc` TO `body`, MODIFY COLUMN `bounded` int NOT NULL CHECK \ + (`bounded` > `base`)" + ] + ); + } + #[test] fn every_type_suggestion_parses() { let dialect = dialect(); diff --git a/linux/crates/drivers/mysql/tests/integration/ddl.rs b/linux/crates/drivers/mysql/tests/integration/ddl.rs index 5ce32effd2..af94b7fa05 100644 --- a/linux/crates/drivers/mysql/tests/integration/ddl.rs +++ b/linux/crates/drivers/mysql/tests/integration/ddl.rs @@ -2,7 +2,8 @@ use tablepro_core::column::ColumnDefault; use tablepro_core::ddl::{ - CollationName, ColumnVisibility, DraftColumn, GeneratedStorage, TableDefinition, TableDraft, diff_table, + BuildDdlError, CollationName, ColumnVisibility, DraftColumn, GeneratedStorage, Permission, RefusalReason, + TableDefinition, TableDraft, diff_table, }; use tablepro_core::{Connection, WriteKind}; use tablepro_test_fixtures::{MysqlFlavour, MysqlOptions}; @@ -250,3 +251,82 @@ async fn mariadb_restated_columns_keep_every_attribute() { ); connection.close().await.expect("a close"); } + +/// MariaDB keeps a column CHECK's constraint name from when it was +/// written, through a rename, so the check is found where SHOW CREATE +/// TABLE prints it. Renaming and restating in one ALTER would splice the +/// check with the old name, so that is refused before anything runs. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn mariadb_check_follows_its_column_through_a_rename() { + let fixture = start(MysqlFlavour::Mariadb).await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &["CREATE TABLE kept (id int PRIMARY KEY, doc json NULL, web point REF_SYSTEM_ID=102100 NULL)"], + ) + .await; + let dialect = connection.dialect(); + let ddl = dialect.ddl().expect("MySQL plans its DDL"); + let original = read(connection.as_ref()).await; + let mut draft = TableDraft::from_definition(&original); + for column in &mut draft.columns { + if column.name == "doc" { + column.name = "body".to_owned(); + column.comment = Some("x".to_owned()); + } + } + + let both = ddl.plan_alter_table(&original, &diff_table(&original, &draft)); + let (_, renamed) = change(connection.as_ref(), |column| { + if column.name == "doc" { + column.name = "body".to_owned(); + } + }) + .await; + let (before, after) = change(connection.as_ref(), |column| { + if column.name == "body" { + column.comment = Some("x".to_owned()); + } + }) + .await; + + assert!( + matches!( + &both, + Err(BuildDdlError::Refused { reason: RefusalReason::NamesRenamedColumn(name), .. }) if name == "doc" + ), + "{both:?}" + ); + let check = |definition: &TableDefinition| { + definition + .column("body") + .and_then(|column| column.attributes.check.as_ref()) + .map(|check| check.as_sql().to_owned()) + }; + assert_eq!(check(&renamed).as_deref(), Some("json_valid(`body`)")); + assert_kept(&before, &after, MysqlFlavour::Mariadb); + assert_eq!(check(&after).as_deref(), Some("json_valid(`body`)")); + assert!( + apply( + connection.as_ref(), + WriteKind::RowChanges, + &["INSERT INTO kept (id, body) VALUES (1, 'not json')"], + ) + .await + .is_err(), + "the json_valid check is gone" + ); + setup(connection.as_ref(), &["ALTER TABLE kept ADD COLUMN doc int NULL"]).await; + let added = read(connection.as_ref()).await; + let doc = added.column("doc").expect("doc"); + assert_eq!(doc.attributes.check, None); + assert!(doc.attributes.unmodeled.is_empty()); + let web = added.column("web").expect("web"); + assert_eq!(web.attributes.spatial_reference, None); + assert_eq!( + ddl.column_capabilities(&added, web).alter_nullability, + Permission::Refused(RefusalReason::UnmodeledAttribute("REF_SYSTEM_ID".to_owned())) + ); + connection.close().await.expect("a close"); +} From a137ab3d743aa8434f5dd8cb7158c860b54260a9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:23:56 +0700 Subject: [PATCH 147/178] fix(linux): refuse a SQL Server change SPARSE or a data mask rejects --- .../mssql/src/catalog/sql_server_type.rs | 9 ++ linux/crates/drivers/mssql/src/ddl.rs | 131 +++++++++++++++++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/linux/crates/drivers/mssql/src/catalog/sql_server_type.rs b/linux/crates/drivers/mssql/src/catalog/sql_server_type.rs index 726f613639..5f4997e478 100644 --- a/linux/crates/drivers/mssql/src/catalog/sql_server_type.rs +++ b/linux/crates/drivers/mssql/src/catalog/sql_server_type.rs @@ -164,6 +164,15 @@ impl SqlServerType { } } + /// SQL Server refuses SPARSE on these system types (error 1731). An + /// alias or CLR type is left to the server, which takes an alias. + pub(crate) fn takes_sparse(&self) -> bool { + !matches!( + self.base.as_str(), + "text" | "ntext" | "image" | "timestamp" | "rowversion" | "geometry" | "geography" + ) + } + /// The type as DDL spells it, modifiers included. pub(crate) fn declaration(&self) -> String { match &self.alias { diff --git a/linux/crates/drivers/mssql/src/ddl.rs b/linux/crates/drivers/mssql/src/ddl.rs index 7b9e9dd59c..752562cdea 100644 --- a/linux/crates/drivers/mssql/src/ddl.rs +++ b/linux/crates/drivers/mssql/src/ddl.rs @@ -8,6 +8,7 @@ use tablepro_core::ddl::{ use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; +use crate::catalog::SqlServerType; use crate::dialect::{MssqlDialect, national_literal}; static CAPABILITIES: DdlCapabilities = DdlCapabilities { @@ -77,7 +78,9 @@ impl DdlDialect for MssqlDialect { /// ALTER COLUMN restates the type with the collation and the /// nullability, so a column whose type the catalogue did not spell, or - /// whose collation is not a name T-SQL takes bare, cannot take one. + /// whose collation is not a name T-SQL takes bare, cannot take one. A + /// SPARSE column is always nullable and takes no default (errors 1731 + /// and 1791), so either change can only be one the server refuses. fn column_capabilities(&self, table: &TableDefinition, column: &ColumnDefinition) -> ColumnCapabilities { let mut permissions = ColumnCapabilities::standard(&CAPABILITIES, table, column); let collation = || collation_permission(&column.attributes); @@ -88,6 +91,11 @@ impl DdlDialect for MssqlDialect { .alter_nullability .and(|| Permission::Refused(RefusalReason::UnreadableDefinition)); } + if column.attributes.sparse { + let sparse = || Permission::Refused(RefusalReason::ConflictingAttribute("SPARSE".to_owned())); + permissions.alter_nullability = permissions.alter_nullability.and(sparse); + permissions.alter_default = permissions.alter_default.and(sparse); + } permissions } @@ -194,6 +202,13 @@ impl DdlDialect for MssqlDialect { true => DdlOperation::AlterColumnType, false => DdlOperation::AlterColumnNullability, }; + if changed.column_type { + kept_on_new_type(&resolved, attributes).map_err(|attribute| BuildDdlError::Refused { + column: alteration.original.name().to_owned(), + operation, + reason: RefusalReason::ConflictingAttribute(attribute), + })?; + } let mut parts = vec![format!("ALTER COLUMN {name}"), resolved.sql.clone()]; parts.extend(collate_clause(draft, &resolved, attributes, operation)?); if attributes.sparse { @@ -351,6 +366,46 @@ fn masked_with(function: &str) -> String { format!("MASKED WITH (FUNCTION = {})", national_literal(function)) } +/// SPARSE and a data mask go back on after a type change, so the new type +/// has to take them. `Err` names the one it does not take. +fn kept_on_new_type(resolved: &ResolvedType, attributes: &ColumnAttributes) -> Result<(), String> { + if attributes.sparse && !SqlServerType::parse(&resolved.sql).takes_sparse() { + return Err("SPARSE".to_owned()); + } + match &attributes.masking_function { + Some(function) if !mask_takes(function, &resolved.column_type.kind) => Err(masked_with(function)), + _ => Ok(()), + } +} + +/// The types each mask function takes; SQL Server refuses the others +/// with error 16003. default() takes any. +fn mask_takes(function: &str, kind: &ColumnKind) -> bool { + let name = function + .split('(') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + match name.as_str() { + "default" => true, + "email" | "partial" => matches!(kind, ColumnKind::Text { .. }), + "random" => matches!( + kind, + ColumnKind::Integer(_) + | ColumnKind::Decimal { .. } + | ColumnKind::Float(_) + | ColumnKind::Money { .. } + | ColumnKind::Bool(_) + ), + "datetime" => matches!( + kind, + ColumnKind::Date | ColumnKind::Time { .. } | ColumnKind::Timestamp { .. } + ), + _ => false, + } +} + fn plain_name(name: &str) -> bool { !name.is_empty() && name @@ -577,7 +632,7 @@ mod tests { let plan = alter(&original, |draft| { draft.columns[2].nullable = false; draft.columns[3].column_type = DraftType::Typed("bigint".to_owned()); - draft.columns[4].column_type = DraftType::Typed("int".to_owned()); + draft.columns[4].column_type = DraftType::Typed("varchar(40)".to_owned()); }); assert_eq!( @@ -585,7 +640,7 @@ mod tests { [ "ALTER TABLE [dbo].[orders] ALTER COLUMN [note] nvarchar(20) COLLATE Latin1_General_100_CS_AS NOT NULL", "ALTER TABLE [dbo].[orders] ALTER COLUMN [weight] bigint SPARSE NULL", - "ALTER TABLE [dbo].[orders] ALTER COLUMN [code] int NULL", + "ALTER TABLE [dbo].[orders] ALTER COLUMN [code] varchar(40) COLLATE Latin1_General_BIN NULL", "ALTER TABLE [dbo].[orders] ALTER COLUMN [code] ADD MASKED WITH (FUNCTION = N'partial(1,\"X''s\",0)')", ] ); @@ -598,6 +653,76 @@ mod tests { ); } + #[test] + fn a_type_that_cannot_keep_the_mask_or_sparse_is_refused() { + let mut original = orders(); + let mut weight = column("weight", "int", None); + weight.attributes.sparse = true; + original.columns.push(weight); + let mut code = column("code", "varchar(10)", None); + code.attributes.masking_function = Some("partial(1,\"X\",0)".to_owned()); + original.columns.push(code); + let mut amount = column("amount", "int", None); + amount.attributes.masking_function = Some("random(1, 9)".to_owned()); + original.columns.push(amount); + let retyped = |column: usize, declared: &str| { + alter(&original, |draft| { + draft.columns[column].column_type = DraftType::Typed(declared.to_owned()) + }) + }; + let conflict = |attribute: &str| { + Err(BuildDdlError::Refused { + column: original.columns[3].name().to_owned(), + operation: DdlOperation::AlterColumnType, + reason: RefusalReason::ConflictingAttribute(attribute.to_owned()), + }) + }; + + assert_eq!(retyped(3, "text"), conflict("SPARSE")); + assert_eq!(retyped(3, "geometry"), conflict("SPARSE")); + assert!(retyped(3, "nvarchar(40)").is_ok()); + assert!(matches!( + retyped(4, "int"), + Err(BuildDdlError::Refused { reason: RefusalReason::ConflictingAttribute(mask), .. }) + if mask == "MASKED WITH (FUNCTION = N'partial(1,\"X\",0)')" + )); + assert!(retyped(4, "nchar(10)").is_ok()); + assert!(matches!( + retyped(5, "varchar(10)"), + Err(BuildDdlError::Refused { + reason: RefusalReason::ConflictingAttribute(_), + .. + }) + )); + assert!(retyped(5, "money").is_ok()); + assert!(mask_takes("default()", &ColumnKind::Uuid)); + assert!(mask_takes("datetime(\"Y\")", &ColumnKind::Date)); + assert!(!mask_takes("email()", &ColumnKind::Date)); + } + + #[test] + fn a_sparse_column_takes_neither_not_null_nor_a_default() { + let mut original = orders(); + let mut weight = column("weight", "int", None); + weight.attributes.sparse = true; + original.columns.push(weight); + let sparse = Permission::Refused(RefusalReason::ConflictingAttribute("SPARSE".to_owned())); + + let permissions = dialect().column_capabilities(&original, &original.columns[3]); + let not_null = alter(&original, |draft| draft.columns[3].nullable = false); + + assert_eq!(permissions.alter_nullability, sparse); + assert_eq!(permissions.alter_default, sparse); + assert_eq!(permissions.alter_type, Permission::Allowed); + assert!(matches!( + not_null, + Err(BuildDdlError::Refused { + operation: DdlOperation::AlterColumnNullability, + .. + }) + )); + } + #[test] fn a_collation_tsql_cannot_name_bare_refuses_a_restatement() { let mut original = orders(); From e879a48881cd7a5137c8299367ebd9c6eb940f81 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:23:56 +0700 Subject: [PATCH 148/178] test(linux): compare every column attribute in the PG and MSSQL tests --- linux/crates/drivers/mssql/tests/integration/ddl.rs | 6 ++++++ linux/crates/drivers/postgres/tests/integration/ddl.rs | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/linux/crates/drivers/mssql/tests/integration/ddl.rs b/linux/crates/drivers/mssql/tests/integration/ddl.rs index bad78cb4f3..8028e1df1c 100644 --- a/linux/crates/drivers/mssql/tests/integration/ddl.rs +++ b/linux/crates/drivers/mssql/tests/integration/ddl.rs @@ -53,6 +53,11 @@ async fn nullability_change_keeps_column_collation() { .expect("the change runs"); let after = read(connection.as_ref()).await; + assert_eq!(before.columns.len(), after.columns.len()); + for old in &before.columns { + let new = after.column(old.name()).expect("the column"); + assert_eq!(new.attributes, old.attributes, "{}", old.name()); + } let column = |name: &str| after.column(name).expect("the column"); let collation = |name: &str| { column(name) @@ -76,4 +81,5 @@ async fn nullability_change_keeps_column_collation() { assert!(mask(&before).is_some_and(|function| function.starts_with("partial("))); assert_eq!(mask(&after), mask(&before)); assert_eq!(column("email").info.column_type.name, "varchar(80)"); + connection.close().await.expect("a close"); } diff --git a/linux/crates/drivers/postgres/tests/integration/ddl.rs b/linux/crates/drivers/postgres/tests/integration/ddl.rs index f0cdd63e32..8a3e0f3d58 100644 --- a/linux/crates/drivers/postgres/tests/integration/ddl.rs +++ b/linux/crates/drivers/postgres/tests/integration/ddl.rs @@ -58,6 +58,16 @@ async fn type_change_keeps_c_collation() { .expect("the change runs"); let after = read(connection.as_ref()).await; + assert_eq!(before.columns.len(), after.columns.len()); + for old in &before.columns { + let mut expected = old.attributes.clone(); + // An integer holds no text, so it has no collation to keep. + if old.name() == "amount" { + expected.collation = None; + } + let new = after.column(old.name()).expect("the column"); + assert_eq!(new.attributes, expected, "{}", old.name()); + } let c = Some(CollationName::new(Some("pg_catalog".to_owned()), "C")); let column = |name: &str| after.column(name).expect("the column"); assert_eq!(column("code").attributes.collation, c); From 6f602e2eea19afc71c6912b8d81e2511ca6307c2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:23:56 +0700 Subject: [PATCH 149/178] docs(linux): record the restatement refusals and SQL Server 2016 floor --- linux/docs/decisions/0010-ddl-plans-and-table-definitions.md | 3 +++ linux/docs/drivers/sql-server.md | 2 ++ 2 files changed, 5 insertions(+) diff --git a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md index 309f088a85..5c4e5127f5 100644 --- a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md +++ b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md @@ -30,6 +30,9 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` - MySQL writes a column again whole for any change to it, its comment included, and SQL Server's ALTER COLUMN carries nothing forward either, so both restate every attribute the definition models where the new type still holds it. On MySQL that is the character set and collation, a generated column's expression, ON UPDATE, INVISIBLE and the SRID; MariaDB adds its REF_SYSTEM_ID and the column's own CHECK, which it keeps apart from the table's. On SQL Server it is the collation, SPARSE, the nullability and a data mask, which goes back on in a statement of its own. - A PostgreSQL type change names the column's collation when the new type holds text, since SET DATA TYPE otherwise takes the type's default. - A column with an attribute this build does not model, or one whose name the restatement cannot write, refuses every change that would restate it. +- MariaDB names a column's CHECK constraint after the column it was written on and keeps that name through a rename, so the check is placed by the column line of SHOW CREATE TABLE it ends. A check no line ends with refuses the restatement of every column without one. +- The server reads a restated expression against the names the same ALTER gives, so a MySQL restatement whose CHECK, generated expression or default names a column renamed in the same change is refused, and the rename is saved on its own first. +- On SQL Server, a type change whose new type cannot take the column's SPARSE or data mask is refused, as is any nullability or default change on a SPARSE column. - Primary-key identity on PostgreSQL and SQLite table rebuilds are plans of their own, added to the same dialects later, and their capabilities are off until then. ## Alternatives considered diff --git a/linux/docs/drivers/sql-server.md b/linux/docs/drivers/sql-server.md index f3a03f9a53..86cab8ab3a 100644 --- a/linux/docs/drivers/sql-server.md +++ b/linux/docs/drivers/sql-server.md @@ -2,6 +2,8 @@ Driver id `mssql`, crate `crates/drivers/mssql`. It runs on tiberius 0.13.0 from crates.io (`tds73`, `rustls`, `chrono`, and `integrated-auth-gssapi` behind the crate's `kerberos` feature) with a deadpool 0.13 pool. The integration suite runs against SQL Server 2022. The contract it implements is in [adding-drivers.md](../adding-drivers.md). +The driver needs SQL Server 2016 or later: every column read joins `sys.masked_columns`, and dropping a table uses `DROP TABLE IF EXISTS`. + ## Connections | Connection | Used for | Session | From 64cf98f91c73f85b51854359532e6feced16ff84 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:24:30 +0700 Subject: [PATCH 150/178] fix(linux): name each tab's own cause in the unknown save prompt --- .../crates/app/src/workspace/unknown_cause.rs | 29 +++++++-- .../src/workspace/unknown_outcome_dialog.rs | 63 +++++++++++++++---- 2 files changed, 77 insertions(+), 15 deletions(-) diff --git a/linux/crates/app/src/workspace/unknown_cause.rs b/linux/crates/app/src/workspace/unknown_cause.rs index 48d4ac5ebc..5d476b5810 100644 --- a/linux/crates/app/src/workspace/unknown_cause.rs +++ b/linux/crates/app/src/workspace/unknown_cause.rs @@ -18,22 +18,43 @@ pub enum UnknownCause { Closed, } +/// The causes the user is told apart. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Wording { + Stopped, + Internal, + ConnectionLost, +} + impl UnknownCause { /// What happened to `tabs`, the tab names already joined. pub fn sentence(&self, tabs: &str) -> String { - match self { - Self::Interrupted => crate::i18n::gettext_f( + match self.wording() { + Wording::Stopped => crate::i18n::gettext_f( "Saving {tabs} was stopped before the database confirmed it. The changes may already be in the database.", &[("tabs", tabs)], ), - Self::Panicked => crate::i18n::gettext_f( + Wording::Internal => crate::i18n::gettext_f( "TablePro hit an internal error while saving {tabs}. The changes may already be in the database.", &[("tabs", tabs)], ), - Self::CommitAckLost { .. } | Self::PartiallyApplied | Self::Closed => crate::i18n::gettext_f( + Wording::ConnectionLost => crate::i18n::gettext_f( "The connection was lost while saving {tabs}. The changes may already be in the database.", &[("tabs", tabs)], ), } } + + /// Whether the user is told the same thing about both. + pub fn reads_as(&self, other: &Self) -> bool { + self.wording() == other.wording() + } + + fn wording(&self) -> Wording { + match self { + Self::Interrupted => Wording::Stopped, + Self::Panicked => Wording::Internal, + Self::CommitAckLost { .. } | Self::PartiallyApplied | Self::Closed => Wording::ConnectionLost, + } + } } diff --git a/linux/crates/app/src/workspace/unknown_outcome_dialog.rs b/linux/crates/app/src/workspace/unknown_outcome_dialog.rs index cea8f7e542..8856e3da56 100644 --- a/linux/crates/app/src/workspace/unknown_outcome_dialog.rs +++ b/linux/crates/app/src/workspace/unknown_outcome_dialog.rs @@ -20,17 +20,7 @@ pub(crate) enum UnknownChoice { } pub(crate) async fn resolve_unknown_outcome(parent: >k::Widget, tabs: &[DirtyTab]) -> UnknownChoice { - let cause = tabs - .iter() - .find_map(|tab| match &tab.kind { - DirtyKind::OutcomeUnknown(cause) => Some(cause.clone()), - _ => None, - }) - .unwrap_or(UnknownCause::Interrupted); - let dialog = adw::AlertDialog::new( - Some(&crate::i18n::gettext("Save Result Unknown")), - Some(&cause.sentence(&DirtyTab::names(tabs))), - ); + let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Save Result Unknown")), Some(&body(tabs))); dialog.add_response(KEEP, &crate::i18n::gettext("_Keep Open")); dialog.add_response(RELOAD, &crate::i18n::gettext("_Reload Table")); dialog.add_response(DISCARD, &crate::i18n::gettext("_Discard Changes")); @@ -43,3 +33,54 @@ pub(crate) async fn resolve_unknown_outcome(parent: >k::Widget, tabs: &[DirtyT _ => UnknownChoice::KeepOpen, } } + +/// One sentence for each cause the user is told apart, naming the tabs +/// it happened to. +fn body(tabs: &[DirtyTab]) -> String { + let mut groups: Vec<(&UnknownCause, Vec)> = Vec::new(); + for tab in tabs { + let DirtyKind::OutcomeUnknown(cause) = &tab.kind else { + continue; + }; + match groups.iter_mut().find(|(seen, _)| seen.reads_as(cause)) { + Some((_, members)) => members.push(tab.clone()), + None => groups.push((cause, vec![tab.clone()])), + } + } + let sentences: Vec = groups + .iter() + .map(|(cause, members)| cause.sentence(&DirtyTab::names(members))) + .collect(); + sentences.join("\n\n") +} + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::*; + + fn tab(title: &str, cause: UnknownCause) -> DirtyTab { + DirtyTab { + id: Uuid::new_v4(), + title: title.to_owned(), + kind: DirtyKind::OutcomeUnknown(cause), + } + } + + #[test] + fn each_tab_is_told_its_own_cause() { + let tabs = [ + tab("orders", UnknownCause::Interrupted), + tab("users", UnknownCause::CommitAckLost { token: None }), + tab("items", UnknownCause::Closed), + ]; + + assert_eq!( + body(&tabs), + "Saving “orders” was stopped before the database confirmed it. The changes may already be in the \ + database.\n\nThe connection was lost while saving “users”, “items”. The changes may already be in the \ + database." + ); + } +} From 7a4b69f5ee0145140e5bedbad3ded5511400e6c5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:24:30 +0700 Subject: [PATCH 151/178] test(linux): fail the stop test when the write never ran --- linux/crates/app/src/workspace/close_guard.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/linux/crates/app/src/workspace/close_guard.rs b/linux/crates/app/src/workspace/close_guard.rs index aa26edadba..40dc7d3a90 100644 --- a/linux/crates/app/src/workspace/close_guard.rs +++ b/linux/crates/app/src/workspace/close_guard.rs @@ -302,6 +302,7 @@ mod tests { assert_eq!(decided(&decision), CloseDecision::Cancelled); let observed = workspace.fake.connection.observed_cancels("write_batch"); + assert!(!observed.is_empty(), "the write never ran"); assert!( observed.iter().all(|cancel| cancel.is_cancelled()), "the write never saw the stop" From 76c359e165fc5a5b8883dfbb094aed58668af958 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:30:21 +0700 Subject: [PATCH 152/178] refactor(linux): move the window close state into tested CloseGuards --- linux/crates/app/src/ui/app/close_flow.rs | 110 +----- linux/crates/app/src/ui/app/mod.rs | 14 +- linux/crates/app/src/workspace/close_guard.rs | 14 +- .../crates/app/src/workspace/close_guards.rs | 329 ++++++++++++++++++ linux/crates/app/src/workspace/mod.rs | 2 + linux/po/POTFILES.in | 1 + 6 files changed, 365 insertions(+), 105 deletions(-) create mode 100644 linux/crates/app/src/workspace/close_guards.rs diff --git a/linux/crates/app/src/ui/app/close_flow.rs b/linux/crates/app/src/ui/app/close_flow.rs index 251db4de68..14160b66ce 100644 --- a/linux/crates/app/src/ui/app/close_flow.rs +++ b/linux/crates/app/src/ui/app/close_flow.rs @@ -1,10 +1,9 @@ use std::cell::Cell; -use std::collections::HashSet; use std::rc::Rc; use relm4::adw::prelude::*; use relm4::gtk::glib; -use relm4::{ComponentSender, adw, gtk}; +use relm4::{ComponentSender, adw}; use uuid::Uuid; use super::workspace_persistence::{WorkspacePersistence, WorkspaceTabs}; @@ -13,39 +12,7 @@ use tablepro_session::runtime::ProcessHold; use crate::services::active_session::ActiveSession; use crate::services::session_context::SessionContext; -use crate::workspace::{CloseDecision, CloseGuard, CloseScope, CloseStep, InhibitReason, WorkspaceHandle}; - -/// Where the window's own close stands. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WindowClose { - Open, - /// The prompts are deciding. - Asking, - /// Nothing is left to lose, so the next close request goes through. - Approved, -} - -/// One close prompt at a time per window, so a tab's close and the -/// window's never ask about the same tab at once: a second waits for the -/// first, then reads the workspace as the first left it. -pub(super) struct CloseGuards { - turn: futures::lock::Mutex<()>, - window: Cell, -} - -impl CloseGuards { - pub(super) fn new() -> Rc { - Rc::new(Self { - turn: futures::lock::Mutex::new(()), - window: Cell::new(WindowClose::Open), - }) - } - - async fn run(&self, parent: >k::Widget, handle: &WorkspaceHandle, scope: CloseScope) -> CloseDecision { - let _turn = self.turn.lock().await; - CloseGuard::run(parent, handle, scope).await - } -} +use crate::workspace::{CloseDecision, InhibitReason}; /// What a window that closes still owes: what it shows written to disk, /// and its connections closed rather than cut off, in order. @@ -138,40 +105,6 @@ fn close_connections( connection::close_window_session(active, hold, attempts); } -/// The window closes only once its prompts approve. A close request -/// while they are deciding is dropped, since their dialog is already -/// on screen. -pub(super) fn connect_window_close( - window: &adw::ApplicationWindow, - guards: Rc, - handle: WorkspaceHandle, - shutdown: Rc, -) { - window.connect_close_request(move |window| { - match guards.window.get() { - WindowClose::Asking => return glib::Propagation::Stop, - WindowClose::Open if handle.close_step(CloseScope::Window, &HashSet::new()) != CloseStep::Approve => { - guards.window.set(WindowClose::Asking); - let (guards, handle, window) = (guards.clone(), handle.clone(), window.clone()); - glib::spawn_future_local(async move { - let decision = guards.run(window.upcast_ref(), &handle, CloseScope::Window).await; - match decision { - CloseDecision::Approved => { - guards.window.set(WindowClose::Approved); - window.close(); - } - CloseDecision::Cancelled => guards.window.set(WindowClose::Open), - } - }); - return glib::Propagation::Stop; - } - WindowClose::Open | WindowClose::Approved => {} - } - shutdown.run(Some(window)); - glib::Propagation::Proceed - }); -} - impl App { /// A tab asked to close. One with nothing to lose closes now; any /// other waits for the prompts. @@ -179,23 +112,17 @@ impl App { let Some(tab_view) = self.workspace_tab_view.clone() else { return; }; - let scope = CloseScope::Tab(id); - if self.workspace_handle.close_step(scope, &HashSet::new()) == CloseStep::Approve { + if self.close_guards.may_close_tab(&self.workspace_handle, id) { self.finish_close_workspace_tab(id, &tab_view); return; } - let (guards, handle, window) = ( - self.close_guards.clone(), - self.workspace_handle.clone(), - self.window.clone(), - ); - glib::spawn_future_local(async move { - let decision = guards.run(window.upcast_ref(), &handle, scope).await; - sender.input(AppMsg::TabCloseDecided { - id, - approved: decision == CloseDecision::Approved, + self.close_guards + .ask_close_tab(self.window.upcast_ref(), &self.workspace_handle, id, move |decision| { + sender.input(AppMsg::TabCloseDecided { + id, + approved: decision == CloseDecision::Approved, + }); }); - }); } pub(super) fn on_tab_close_decided(&mut self, id: Uuid, approved: bool) { @@ -219,20 +146,17 @@ impl App { /// Send `proceed` once every tab may close, for a disconnect or a /// switch to another connection, which take the tabs with them. pub(super) fn close_workspace_then(&self, proceed: AppMsg, sender: ComponentSender) { - if self.workspace_handle.close_step(CloseScope::Window, &HashSet::new()) == CloseStep::Approve { + self.close_guards.begin_teardown(); + if self.close_guards.may_tear_down(&self.workspace_handle) { sender.input(proceed); return; } - let (guards, handle, window) = ( - self.close_guards.clone(), - self.workspace_handle.clone(), - self.window.clone(), - ); - glib::spawn_future_local(async move { - if guards.run(window.upcast_ref(), &handle, CloseScope::Window).await == CloseDecision::Approved { - sender.input(proceed); - } - }); + self.close_guards + .ask_tear_down(self.window.upcast_ref(), &self.workspace_handle, move |decision| { + if decision == CloseDecision::Approved { + sender.input(proceed); + } + }); } /// Logout, and suspend while a save runs, wait for the user while diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index c531a90a02..509f0d7a50 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -113,7 +113,7 @@ pub struct App { window_tabs: std::rc::Rc, /// One close prompt at a time, for a tab, the window, a disconnect /// or a switch. - close_guards: std::rc::Rc, + close_guards: std::rc::Rc, /// What closing the window still owes its files and connections. window_shutdown: std::rc::Rc, /// Holds logout back while something here would be lost. @@ -692,7 +692,7 @@ impl SimpleComponent for App { workspace_handle .ledger() .on_change(move |count| saves_sender.emit(AppMsg::SavesInFlightChanged(count))); - let close_guards = close_flow::CloseGuards::new(); + let close_guards = crate::workspace::CloseGuards::new(); let window_shutdown = close_flow::WindowShutdown::new(close_flow::WindowShutdownParts { settings: settings.clone(), storage: storage.clone(), @@ -702,12 +702,10 @@ impl SimpleComponent for App { context: context.clone(), connect_attempts: connect_attempts.clone(), }); - close_flow::connect_window_close( - &widgets.window, - close_guards.clone(), - workspace_handle.clone(), - window_shutdown.clone(), - ); + let shutdown = window_shutdown.clone(); + close_guards.connect_window(&widgets.window, workspace_handle.clone(), move |window| { + shutdown.run(Some(window)); + }); let inhibitor = crate::workspace::WindowInhibitor::new(crate::workspace::ApplicationInhibit::new(&widgets.window)); diff --git a/linux/crates/app/src/workspace/close_guard.rs b/linux/crates/app/src/workspace/close_guard.rs index 40dc7d3a90..8c4732271c 100644 --- a/linux/crates/app/src/workspace/close_guard.rs +++ b/linux/crates/app/src/workspace/close_guard.rs @@ -19,10 +19,16 @@ use super::{CloseDecision, CloseScope, CloseStep, DirtyTab, SaveRejection, SaveS pub(crate) struct CloseGuard; impl CloseGuard { - pub(crate) async fn run(parent: >k::Widget, handle: &WorkspaceHandle, scope: CloseScope) -> CloseDecision { - let mut acknowledged = HashSet::new(); + /// `acknowledged` holds the tabs whose open transactions the user + /// already agreed to lose, and gains the ones agreed to here. + pub(crate) async fn run( + parent: >k::Widget, + handle: &WorkspaceHandle, + scope: CloseScope, + acknowledged: &mut HashSet, + ) -> CloseDecision { loop { - match handle.close_step(scope, &acknowledged) { + match handle.close_step(scope, acknowledged) { CloseStep::Approve => return CloseDecision::Approved, CloseStep::WaitForSaves => { if !wait_for_saves(parent, handle, scope).await { @@ -121,7 +127,7 @@ mod tests { let parent = window.clone().upcast::(); let handle = handle.clone(); gtk::glib::spawn_future_local(async move { - let decided = CloseGuard::run(&parent, &handle, scope).await; + let decided = CloseGuard::run(&parent, &handle, scope, &mut HashSet::new()).await; sink.replace(Some(decided)); }); decision diff --git a/linux/crates/app/src/workspace/close_guards.rs b/linux/crates/app/src/workspace/close_guards.rs new file mode 100644 index 0000000000..678290567e --- /dev/null +++ b/linux/crates/app/src/workspace/close_guards.rs @@ -0,0 +1,329 @@ +use std::cell::{Cell, RefCell}; +use std::collections::HashSet; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use uuid::Uuid; + +use super::{CloseDecision, CloseGuard, CloseScope, CloseStep, WorkspaceHandle}; + +/// Where the window's own close stands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WindowClose { + Open, + /// The prompts are deciding. + Asking, + /// Nothing is left to lose, so the next close request goes through. + Approved, +} + +/// A window's close prompts. One runs at a time, so a tab's close and +/// the window's never ask about the same tab at once: a second waits for +/// the first, then reads the workspace as the first left it. +pub(crate) struct CloseGuards { + turn: futures::lock::Mutex<()>, + window: Cell, + /// Open transactions the user agreed to lose in the current + /// disconnect or switch. A switch asks again once its connection is + /// ready, about what changed while it connected, but not about these. + teardown_acknowledged: RefCell>, +} + +impl CloseGuards { + pub(crate) fn new() -> Rc { + Rc::new(Self { + turn: futures::lock::Mutex::new(()), + window: Cell::new(WindowClose::Open), + teardown_acknowledged: RefCell::new(HashSet::new()), + }) + } + + async fn run( + &self, + parent: >k::Widget, + handle: &WorkspaceHandle, + scope: CloseScope, + acknowledged: &mut HashSet, + ) -> CloseDecision { + let _turn = self.turn.lock().await; + CloseGuard::run(parent, handle, scope, acknowledged).await + } + + /// The window closes only once its prompts approve; `on_close` runs + /// as it does. A close request while they decide is dropped, since + /// their dialog is already on screen. + pub(crate) fn connect_window>( + self: &Rc, + window: &W, + handle: WorkspaceHandle, + on_close: impl Fn(&W) + 'static, + ) { + let guards = self.clone(); + window.connect_close_request(move |window| { + if !guards.window_may_close(window.upcast_ref(), &handle) { + return glib::Propagation::Stop; + } + on_close(window); + glib::Propagation::Proceed + }); + } + + fn window_may_close(self: &Rc, window: >k::Window, handle: &WorkspaceHandle) -> bool { + match self.window.get() { + WindowClose::Asking => return false, + WindowClose::Approved => return true, + WindowClose::Open => {} + } + if handle.close_step(CloseScope::Window, &HashSet::new()) == CloseStep::Approve { + return true; + } + self.window.set(WindowClose::Asking); + let (guards, handle, window) = (self.clone(), handle.clone(), window.clone()); + glib::spawn_future_local(async move { + let decision = guards + .run(window.upcast_ref(), &handle, CloseScope::Window, &mut HashSet::new()) + .await; + match decision { + CloseDecision::Approved => { + guards.window.set(WindowClose::Approved); + window.close(); + } + CloseDecision::Cancelled => guards.window.set(WindowClose::Open), + } + }); + false + } + + pub(crate) fn may_close_tab(&self, handle: &WorkspaceHandle, tab: Uuid) -> bool { + handle.close_step(CloseScope::Tab(tab), &HashSet::new()) == CloseStep::Approve + } + + pub(crate) fn ask_close_tab( + self: &Rc, + parent: >k::Widget, + handle: &WorkspaceHandle, + tab: Uuid, + decided: impl FnOnce(CloseDecision) + 'static, + ) { + let (guards, handle, parent) = (self.clone(), handle.clone(), parent.clone()); + glib::spawn_future_local(async move { + let decision = guards + .run(&parent, &handle, CloseScope::Tab(tab), &mut HashSet::new()) + .await; + decided(decision); + }); + } + + /// A new disconnect or switch asks about every open transaction. + pub(crate) fn begin_teardown(&self) { + self.teardown_acknowledged.borrow_mut().clear(); + } + + /// Whether every tab may go with the connection without asking. + pub(crate) fn may_tear_down(&self, handle: &WorkspaceHandle) -> bool { + handle.close_step(CloseScope::Window, &self.teardown_acknowledged.borrow()) == CloseStep::Approve + } + + /// Ask whether every tab may go with the connection; `decided` hears + /// the answer. + pub(crate) fn ask_tear_down( + self: &Rc, + parent: >k::Widget, + handle: &WorkspaceHandle, + decided: impl FnOnce(CloseDecision) + 'static, + ) { + let (guards, handle, parent) = (self.clone(), handle.clone(), parent.clone()); + glib::spawn_future_local(async move { + let mut acknowledged = guards.teardown_acknowledged.borrow().clone(); + let decision = guards + .run(&parent, &handle, CloseScope::Window, &mut acknowledged) + .await; + if decision == CloseDecision::Approved { + guards.teardown_acknowledged.replace(acknowledged); + } + decided(decision); + }); + } +} + +#[cfg(test)] +mod tests { + use libadwaita as adw; + use libadwaita::prelude::*; + use tablepro_core::Value; + use tablepro_test_support::ConnectionScript; + + use super::*; + use crate::services::change_tracker::RowKey; + use crate::services::tracker_edit::TrackerEdit; + use crate::test_support::{drain_main_context, wait_until}; + use crate::workspace::dialog_response::{answer, visible_alert}; + use crate::workspace::test_workspace::{WAIT, Workspace}; + + fn window() -> adw::Window { + adw::init().expect("libadwaita initialises under the test backend"); + if let Some(settings) = gtk::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let window = adw::Window::new(); + window.present(); + window + } + + /// A window whose close goes through `guards`, and how many times it + /// closed. + fn guarded_window(guards: &Rc, workspace: &Workspace) -> (adw::Window, Rc>) { + let window = window(); + let closes = Rc::new(Cell::new(0)); + let sink = closes.clone(); + guards.connect_window(&window, workspace.handle.clone(), move |_| sink.set(sink.get() + 1)); + (window, closes) + } + + /// Wait for an alert with `heading` other than `seen`, and answer it + /// with `response`. Returns the alert answered. + fn answer_next( + window: &adw::Window, + heading: &str, + response: &str, + seen: Option<&adw::AlertDialog>, + ) -> adw::AlertDialog { + let mut shown = None; + wait_until(WAIT, || { + shown = visible_alert(window) + .filter(|alert| alert.heading().as_deref() == Some(heading) && Some(alert) != seen); + shown.is_some() + }) + .unwrap_or_else(|_| panic!("no “{heading}” alert")); + assert!(answer(window, response)); + shown.expect("the alert") + } + + fn edit_again(workspace: &Workspace) { + let _ = workspace.tracker.apply(TrackerEdit::Cell { + row_key: RowKey::from_pk_values(&[Value::Int(2)]).expect("a key"), + col: 1, + original: Value::Int(1), + value: Value::Int(7), + }); + } + + fn decision_sink() -> (Rc>>, impl FnOnce(CloseDecision) + 'static) { + let decision = Rc::new(Cell::new(None)); + let sink = decision.clone(); + (decision, move |decided| sink.set(Some(decided))) + } + + #[gtk4::test] + fn a_clean_window_closes_without_asking() { + let workspace = Workspace::open(ConnectionScript::default()); + workspace.handle.discard(&[workspace.tab], false); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + + window.close(); + + assert_eq!(closes.get(), 1); + assert!(visible_alert(&window).is_none()); + } + + /// libadwaita takes a close request over a dialog as dismissing the + /// dialog, which keeps the window open. + #[gtk4::test] + fn a_close_request_over_the_prompt_keeps_the_window() { + let workspace = Workspace::open(ConnectionScript::default()); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + + window.close(); + wait_until(WAIT, || visible_alert(&window).is_some()).expect("the prompt"); + let first = visible_alert(&window); + window.close(); + wait_until(WAIT, || guards.window.get() == WindowClose::Open).expect("the prompt to be dismissed"); + + assert_eq!(closes.get(), 0, "the window closed before the prompt approved"); + assert!(window.is_visible()); + window.close(); + answer_next(&window, "Save Changes?", "discard", first.as_ref()); + wait_until(WAIT, || closes.get() == 1).expect("the approved close to go through"); + drain_main_context(); + assert_eq!(closes.get(), 1); + } + + #[gtk4::test] + fn a_kept_window_asks_again_on_the_next_close() { + let workspace = Workspace::open(ConnectionScript::default()); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + + window.close(); + let kept = answer_next(&window, "Save Changes?", "cancel", None); + wait_until(WAIT, || guards.window.get() == WindowClose::Open).expect("the close to be dropped"); + assert!(window.is_visible() && closes.get() == 0); + window.close(); + answer_next(&window, "Save Changes?", "discard", Some(&kept)); + + wait_until(WAIT, || closes.get() == 1).expect("the approved close to go through"); + } + + #[gtk4::test] + fn a_teardown_proceeds_only_when_approved() { + let workspace = Workspace::open(ConnectionScript::default()); + let guards = CloseGuards::new(); + let window = window(); + let parent = window.clone().upcast::(); + assert!(!guards.may_tear_down(&workspace.handle)); + + let (kept, sink) = decision_sink(); + guards.ask_tear_down(&parent, &workspace.handle, sink); + let first = answer_next(&window, "Save Changes?", "cancel", None); + wait_until(WAIT, || kept.get().is_some()).expect("a decision"); + assert_eq!(kept.get(), Some(CloseDecision::Cancelled)); + assert!(!guards.may_tear_down(&workspace.handle)); + + let (approved, sink) = decision_sink(); + guards.ask_tear_down(&parent, &workspace.handle, sink); + answer_next(&window, "Save Changes?", "discard", Some(&first)); + wait_until(WAIT, || approved.get().is_some()).expect("a decision"); + assert_eq!(approved.get(), Some(CloseDecision::Approved)); + assert!(guards.may_tear_down(&workspace.handle)); + } + + /// A switch asks once before it connects and again before the new + /// connection replaces the tabs, about what changed in between. + #[gtk4::test] + fn a_switch_asks_again_only_about_what_changed_while_connecting() { + let workspace = Workspace::open(ConnectionScript::default()); + workspace.open_transaction_tab(); + let guards = CloseGuards::new(); + let window = window(); + let parent = window.clone().upcast::(); + + guards.begin_teardown(); + let (approved, sink) = decision_sink(); + guards.ask_tear_down(&parent, &workspace.handle, sink); + answer_next(&window, "Close With Open Transactions?", "close", None); + let first = answer_next(&window, "Save Changes?", "discard", None); + wait_until(WAIT, || approved.get().is_some()).expect("a decision"); + assert!(guards.may_tear_down(&workspace.handle)); + + edit_again(&workspace); + assert!( + !guards.may_tear_down(&workspace.handle), + "an edit made while connecting went unasked" + ); + let (again, sink) = decision_sink(); + guards.ask_tear_down(&parent, &workspace.handle, sink); + answer_next(&window, "Save Changes?", "discard", Some(&first)); + wait_until(WAIT, || again.get().is_some()).expect("a decision"); + assert_eq!(again.get(), Some(CloseDecision::Approved)); + + guards.begin_teardown(); + assert!( + !guards.may_tear_down(&workspace.handle), + "a new switch skipped the transaction" + ); + } +} diff --git a/linux/crates/app/src/workspace/mod.rs b/linux/crates/app/src/workspace/mod.rs index ac3aa031cc..d61d185f4d 100644 --- a/linux/crates/app/src/workspace/mod.rs +++ b/linux/crates/app/src/workspace/mod.rs @@ -4,6 +4,7 @@ mod close_decision; mod close_guard; +mod close_guards; mod close_scope; mod close_step; mod dialog_response; @@ -49,6 +50,7 @@ mod write_job; pub use close_decision::CloseDecision; pub(crate) use close_guard::CloseGuard; +pub(crate) use close_guards::CloseGuards; pub use close_scope::CloseScope; pub use close_step::{CloseStep, next_close_step}; pub use dirty_kind::DirtyKind; diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index f933e7b699..9a81edbb45 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -176,6 +176,7 @@ crates/app/src/ui/welcome_view.rs crates/app/src/ui/window_geometry.rs crates/app/src/workspace/close_decision.rs crates/app/src/workspace/close_guard.rs +crates/app/src/workspace/close_guards.rs crates/app/src/workspace/close_scope.rs crates/app/src/workspace/close_step.rs crates/app/src/workspace/dialog_response.rs From 7249e3eb9e7b981911fe8414710b34337f89fc87 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:30:26 +0700 Subject: [PATCH 153/178] fix(linux): ask again before a switch replaces the tabs --- linux/crates/app/src/ui/app/connection.rs | 43 +++++++++++++++++------ linux/crates/app/src/ui/app/mod.rs | 3 ++ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index 8bb067b103..cdfbd702c1 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -23,6 +23,7 @@ use crate::ui::connect_dialog::{ConnectDialog, ConnectDialogInit, ConnectPrefill use crate::ui::error_text::TlsRemedy; use crate::ui::file_read::{FileReadError, read_capped_file}; use crate::ui::ssh_inputs::SshEnvironment; +use crate::workspace::CloseDecision; use super::connect_flow::{self, SubmitError}; use super::{App, AppMsg, qualified_label}; @@ -258,27 +259,42 @@ impl App { /// Adopt a session that connected: claim its key, start it, and /// build the workspace on its catalog. pub(super) fn on_session_ready(&mut self, ready: ReadySession, sender: ComponentSender) { - let ReadySession { - key, - pending, - credentials, - warnings, - generation, - } = ready; - if generation.is_some_and(|generation| !self.connect_attempts.accepts(generation)) { - self.discard_pending(pending); + if ready + .generation + .is_some_and(|generation| !self.connect_attempts.accepts(generation)) + { + self.discard_pending(ready.pending); return; } self.dismiss_loading_page(); - if self.session_links.as_ref().is_some_and(|links| links.key == key) { - self.discard_pending(pending); + if self.session_links.as_ref().is_some_and(|links| links.key == ready.key) { + self.discard_pending(ready.pending); self.show_toast(&crate::i18n::gettext("This connection is already open.")); return; } // One window shows one database, so the one open now goes first. + // The switch asked before it connected; this asks again about + // anything edited or begun while it connected. if self.context.active.is_connected() { + if !self.close_guards.may_tear_down(&self.workspace_handle) { + self.close_guards + .ask_tear_down(self.window.upcast_ref(), &self.workspace_handle, move |decision| { + sender.input(match decision { + CloseDecision::Approved => AppMsg::SessionReady(Box::new(ready)), + CloseDecision::Cancelled => AppMsg::SessionDeclined(Box::new(ready)), + }); + }); + return; + } self.release_session(sender.clone()); } + let ReadySession { + key, + pending, + credentials, + warnings, + generation: _, + } = ready; let claim = match self.context.sessions.claim(key.clone(), self.window.id()) { Ok(claim) => claim, Err(ClaimError::AlreadyOpen { .. }) => { @@ -360,6 +376,11 @@ impl App { /// Throw away a connection nobody adopted, closing it under the /// close deadline while the process stays alive for it. + /// The user kept the tabs a switch would have closed. + pub(super) fn on_session_declined(&self, ready: ReadySession) { + self.discard_pending(ready.pending); + } + fn discard_pending(&self, pending: PendingSession) { let hold = self.context.hold.acquire(); let closing = self.context.tasks.spawn_task(pending.discard()); diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 509f0d7a50..6e9c735e71 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -270,6 +270,8 @@ pub enum AppMsg { OpenConnect, /// A connect worked; adopt its session in this window. SessionReady(Box), + /// The user kept the tabs the connect would have replaced. + SessionDeclined(Box), /// The connect dialog's Connect: connect, then save the entry. ConnectSubmitted(Box), /// The same, once the user agreed to close the open workspace. @@ -1053,6 +1055,7 @@ impl SimpleComponent for App { match msg { AppMsg::OpenConnect => self.on_open_connect(sender), AppMsg::SessionReady(ready) => self.on_session_ready(*ready, sender), + AppMsg::SessionDeclined(ready) => self.on_session_declined(*ready), AppMsg::ConnectSubmitted(submission) => self.on_connect_submission(*submission, sender), AppMsg::ConnectSubmissionConfirmed(submission) => self.connect_submission(*submission, sender), AppMsg::ConnectFailed { From 83ba1629fd3c8c87fd6f1919e55c28e435bb4a98 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:30:26 +0700 Subject: [PATCH 154/178] test(linux): pin the order a closing window closes its connections --- linux/crates/app/src/ui/app/close_flow.rs | 29 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/linux/crates/app/src/ui/app/close_flow.rs b/linux/crates/app/src/ui/app/close_flow.rs index 14160b66ce..9d152d6341 100644 --- a/linux/crates/app/src/ui/app/close_flow.rs +++ b/linux/crates/app/src/ui/app/close_flow.rs @@ -177,7 +177,7 @@ mod tests { use tablepro_core::{EditorSession, TransactionState}; use tablepro_session::ConnectAttempts; - use tablepro_session::runtime::CountingHold; + use tablepro_session::runtime::{CountingHold, HoldToken}; use tablepro_storage::DraftId; use tablepro_test_support::{ConnectionScript, FakeEditorSession, FakeReply, FakeRun, Replies}; @@ -190,6 +190,21 @@ mod tests { const WAIT: Duration = Duration::from_secs(10); + /// Names each hold it gives out, so a test reads which close began + /// first: every close holds the process from the moment it starts. + struct NamedHold { + name: &'static str, + log: Rc>>, + counting: Rc, + } + + impl ProcessHold for NamedHold { + fn acquire(&self) -> HoldToken { + self.log.borrow_mut().push(self.name); + self.counting.acquire() + } + } + #[gtk4::test] fn closing_the_window_names_open_transactions_and_closes_every_connection() { adw::init().expect("libadwaita initialises under the test backend"); @@ -207,13 +222,19 @@ mod tests { let active = ActiveSession::new(); active.replace(fake.session.clone()); let hold = Rc::new(CountingHold::new()); + let log = Rc::new(RefCell::new(Vec::new())); + let named = |name| NamedHold { + name, + log: log.clone(), + counting: hold.clone(), + }; let editor = SqlEditor::new(SqlEditorInit { initial_query: Some("BEGIN; UPDATE t SET a = 1".to_owned()), settings: settings.get().clone(), history: None, tasks: runtime.tasks(), session: active.clone(), - hold: hold.clone(), + hold: Rc::new(named("editor tab")), close_timeout: Duration::from_secs(5), }); editor.run(); @@ -243,8 +264,10 @@ mod tests { }] ); - close_connections(&tabs, &active, &*hold, &ConnectAttempts::new()); + log.borrow_mut().clear(); + close_connections(&tabs, &active, &named("window session"), &ConnectAttempts::new()); + assert_eq!(*log.borrow(), ["editor tab", "window session"]); assert!(!active.is_connected()); wait_until(WAIT, || { editor_session.close_count() == 1 && fake.connection.is_closed() && hold.live() == 0 From 4565be2459e57a592f2d2519a168c7892477d97d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:30:36 +0700 Subject: [PATCH 155/178] docs(linux): note the second switch prompt in the changelog --- linux/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 4e21c95cf5..6d89882b78 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -75,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Typing in a long SQL script copied the whole script on every keystroke. - Closing the window never asked about open editor transactions, and never closed the editor tabs' connections. - Disconnecting or switching connections offered no way to save pending changes first. +- Edits made while a switch to another connection was connecting were closed without asking. - A save whose answer was lost with the connection showed as failed, and saving again could apply it twice. - Edits made in a table tab while its save ran were dropped when the save finished. - Structure tab edits made while its save ran were lost when the table was read again. From 1aa66a76ec3858877827f27f20222e321ed084d1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 01:35:34 +0700 Subject: [PATCH 156/178] refactor(linux): drop an import the wave 5 merge left unused --- linux/crates/app/src/ui/app/row_ops.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/linux/crates/app/src/ui/app/row_ops.rs b/linux/crates/app/src/ui/app/row_ops.rs index 3ace04c3d8..1a5d65c209 100644 --- a/linux/crates/app/src/ui/app/row_ops.rs +++ b/linux/crates/app/src/ui/app/row_ops.rs @@ -1,5 +1,4 @@ use relm4::adw::prelude::*; -use uuid::Uuid; use super::App; From 8d1c750184ef17e9b316639d2cfbf759394465d6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:03:42 +0700 Subject: [PATCH 157/178] fix(linux-grid): redraw tracked rows in place instead of swapping them --- linux/ARCHITECTURE.md | 2 +- linux/CHANGELOG.md | 1 + .../crates/app/src/services/change_tracker.rs | 4 +- .../crates/app/src/services/tracker_delta.rs | 2 +- linux/crates/app/src/ui/browse_tab/mod.rs | 144 +++++----- linux/crates/app/src/ui/browse_tab/tests.rs | 234 ++++++++++++++-- linux/crates/app/src/ui/grid/cell_binding.rs | 39 +++ linux/crates/app/src/ui/grid/cell_editor.rs | 6 +- .../crates/app/src/ui/grid/column_drawing.rs | 134 +++++++++ .../crates/app/src/ui/grid/column_factory.rs | 259 ++++++++---------- linux/crates/app/src/ui/grid/grid_cell.rs | 57 +++- linux/crates/app/src/ui/grid/grid_row_id.rs | 20 -- .../crates/app/src/ui/grid/grid_row_index.rs | 111 ++++++++ linux/crates/app/src/ui/grid/mod.rs | 4 + linux/crates/app/src/ui/grid/row_object.rs | 44 ++- linux/data/resources/style.css | 4 +- linux/po/POTFILES.in | 3 + 17 files changed, 769 insertions(+), 299 deletions(-) create mode 100644 linux/crates/app/src/ui/grid/cell_binding.rs create mode 100644 linux/crates/app/src/ui/grid/column_drawing.rs create mode 100644 linux/crates/app/src/ui/grid/grid_row_index.rs diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 190fca8b91..c9677a9dfe 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -225,7 +225,7 @@ Each window owns two registries holding the in-flight edit state for its tabs, k | `ChangeTrackerRegistry` | `TabTrackerHandle` | Row-level INSERT / UPDATE / DELETE for Table tabs | `TabTrackerHandle::change_set` → `TrackedChanges` | | `StructureTrackerRegistry` | `StructureTrackerHandle` | The table's definition, the user's `TableDraft` and the `TableChanges` between them, for Structure tabs | `PendingStructure::plan` through the connection's `DdlDialect` → `DdlPlan` | -A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to rebind, the draft rows the list store gains or loses, and the new pending count. The tab applies it to its `gio::ListStore` after the call returns. A grid row is a `RowObject` whose identity is fixed when it is made: a saved row's key and cells as fetched, or a draft's id, whose cells live only in the tracker. A grid cell resolves its row through its column view cell when an event fires, so an edit, a menu action or a toggle reaches the row it was made on even after rows above it came or went. Every pending change carries a `Touch` from the tracker's counter. An `UndoOp` holds the pending state on both sides of a step, touch included, so undo after A, B, C shows B, not the fetched value. `change_set` emits one change per row in touch order, and `plan_save` keeps that order: a row deleted and replaced by a draft with the same unique value saves, because its DELETE runs first. `App::dirty_tabs` reads both registries for the close prompts, the teardown guard and the `•` title prefix. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. +A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to redraw, the draft rows the list store gains or loses, and the new pending count. The tab applies it to its `gio::ListStore` after the call returns. A grid row is a `RowObject` whose identity is fixed when it is made: a saved row's key and cells as fetched, or a draft's id, whose cells live only in the tracker. A tracked change bumps the row's `revision` property, and each cell bound to the row listens for it and draws again in place, so the store never swaps a row and its selection and focus stay put. The tab finds the row through a `GridRowIndex` of weak references keyed like the tracker. A grid cell resolves its row through its column view cell when an event fires, so an edit, a menu action or a toggle reaches the row it was made on even after rows above it came or went. Every pending change carries a `Touch` from the tracker's counter. An `UndoOp` holds the pending state on both sides of a step, touch included, so undo after A, B, C shows B, not the fetched value. `change_set` emits one change per row in touch order, and `plan_save` keeps that order: a row deleted and replaced by a draft with the same unique value saves, because its DELETE runs first. `App::dirty_tabs` reads both registries for the close prompts, the teardown guard and the `•` title prefix. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. The Structure tab is **definition + draft**, not a per-change log. The `TableDefinition` one read returned is what the tab plans against; `diff_table` turns the draft into `TableChanges`, and the engine's `DdlDialect` plans them into statements in its own order. Discard copies the definition back over the draft, and is the only restore point. The tracker holds the latest `PendingStructure`, so the close prompt and the save dispatcher read the same change without the tab, and the save plans it again with the dialect of the connection that runs it ([ADR 0010](docs/decisions/0010-ddl-plans-and-table-definitions.md)). diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 050b0c10e4..a74df61d21 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ClickHouse save with an unknown outcome could read as applied because of another table's mutation. - A new row removed with Delete stayed in the grid while the save left it out. - An edit, Set Value or row action in the table grid could land on another row once a row above it came or went. +- Editing, undoing or deleting a row in the table grid dropped it from a selection of several rows. - Copy as INSERT could copy the wrong row, drop the schema or unsaved edits, or write a value where the server fills one. - Duplicate left out the row's unsaved edits. - Cells of a table without a primary key opened for editing, then dropped what was typed. diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index 70df055a52..531eef7ba6 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -133,7 +133,7 @@ impl TabChangeTracker { self.drafts.len() + self.deletes.len() + self.updates.len() } - /// An edit the running save holds back comes back as a rebind of its + /// An edit the running save holds back comes back as a redraw of its /// row, so the grid stops showing what was typed into it. pub fn apply(&mut self, edit: TrackerEdit) -> TrackerDelta { if self.edit_is_in_flight(&edit) { @@ -475,7 +475,7 @@ impl TabChangeTracker { } /// Clear the flash unless a newer one has started. Returns whether - /// it cleared, so the caller knows the row needs a rebind. + /// it cleared, so the caller knows the row needs a redraw. pub fn clear_error_row_if(&mut self, generation: u64) -> bool { if self.error_row_gen != generation { return false; diff --git a/linux/crates/app/src/services/tracker_delta.rs b/linux/crates/app/src/services/tracker_delta.rs index 6529eedeb0..13fd2c8686 100644 --- a/linux/crates/app/src/services/tracker_delta.rs +++ b/linux/crates/app/src/services/tracker_delta.rs @@ -4,7 +4,7 @@ use super::change_tracker::RowKey; /// tracker is no longer borrowed so the caller can touch GTK freely. #[derive(Debug, Clone, Default, PartialEq)] pub struct TrackerDelta { - /// Rows whose pending state changed and need a rebind. + /// Rows whose pending state changed and need a redraw. pub rows: Vec, /// Draft rows the list store has to gain or lose. pub visual: Vec, diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 0e250c860f..87022c4117 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -36,7 +36,7 @@ pub(crate) use self::browse_command::{BrowseCommand, ColumnsReply}; pub(crate) use self::browse_request_kind::BrowseRequestKind; pub(crate) use self::page_reply::PageReply; use super::grid::{ - CellPreset, ColumnWidthBinding, GridCell, GridMsg, GridRowId, GridSink, RowObject, TabGridContext, + CellPreset, ColumnWidthBinding, GridCell, GridMsg, GridRowId, GridRowIndex, GridSink, RowObject, TabGridContext, build_column_view, cell_editability, }; use crate::runtime::{FailedRequests, RequestSequencer}; @@ -188,6 +188,7 @@ impl BrowseTab { loading_page: imp.loading_page.get(), error_page: imp.error_page.get(), current_column_view: None, + row_index: GridRowIndex::default(), rendered_columns: Vec::new(), read_only_banner: imp.read_only_banner.get(), no_pk_banner: imp.no_pk_banner.get(), @@ -563,6 +564,9 @@ pub(crate) struct BrowseModel { loading_page: adw::StatusPage, error_page: adw::StatusPage, current_column_view: Option, + /// The store's rows by tracker key, so a tracked change finds the + /// row to redraw without a scan of the page. + row_index: GridRowIndex, /// The columns the current view was built for. A page under the /// same columns reuses the view and only swaps its rows; a schema /// change that renames or retypes one builds the view again. @@ -682,6 +686,8 @@ pub(crate) enum BrowseTabInput { /// refuses to save them again until the table is read again. SaveOutcomeUnknown(String), FlashErrorRow(StatementSource), + /// The flash on a failed statement's row ran its course. + ErrorFlashEnded(RowKey), CopySelectedRowsAsTsv, PasteNotSupported, SelectAllRows, @@ -845,16 +851,17 @@ impl BrowseModel { } /// Apply what a tracker call changed: the draft rows the store gains - /// or loses, then a rebind of each touched row, then the footer. - fn apply_tracker_delta(&self, delta: TrackerDelta, fx: &mut Effects) { + /// or loses, then a redraw of each touched row, then the footer. A + /// row keeps its object, so the selection and the focus stay on it. + fn apply_tracker_delta(&mut self, delta: &TrackerDelta, fx: &mut Effects) { if let Some(store) = self.list_store() { for change in &delta.visual { - apply_visual_change(&store, change); + apply_visual_change(&store, &mut self.row_index, change); } } for key in &delta.rows { - if let Some((_, row)) = self.row_for_key(key) { - self.refresh_row(&row); + if let Some(row) = self.row_index.get(key) { + row.bump_revision(); } } self.sync_pending_chrome(delta.pending_count, fx); @@ -870,19 +877,19 @@ impl BrowseModel { } } - fn apply_edit(&self, edit: TrackerEdit, fx: &mut Effects) { + fn apply_edit(&mut self, edit: TrackerEdit, fx: &mut Effects) { if self.tracker.refuses(&edit) && !std::mem::replace(&mut fx.told_edit_held, true) { fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( "This row is being saved. Change it again once the save finishes.", ))); } let delta = self.tracker.apply(edit); - self.apply_tracker_delta(delta, fx); + self.apply_tracker_delta(&delta, fx); } /// A typed or preset value for one cell, against the row the edit /// was made on, wherever it sits now. - fn edit_cell(&self, row: &RowObject, col: usize, value: Value, fx: &mut Effects) { + fn edit_cell(&mut self, row: &RowObject, col: usize, value: Value, fx: &mut Effects) { match row.id() { GridRowId::Draft(draft_id) => { self.apply_edit( @@ -934,49 +941,21 @@ impl BrowseModel { selection.model()?.downcast::().ok() } - /// Make the view rebind `row`, which keeps its identity but whose - /// pending state changed. - /// - /// The ColumnView's list-item manager compares item pointers and - /// skips the rebind when the item at a position is the same object. - /// So a fresh object for the row is spliced in: one items-changed, - /// no flicker, no scroll jump. An event can carry an object an - /// earlier rebind replaced, so a keyed row is found by its key. - fn refresh_row(&self, row: &RowObject) { - let Some(store) = self.list_store() else { - return; - }; - let position = store.find(row).or_else(|| { - let key = row.id().tracker_key()?; - self.row_for_key(&key).map(|(position, _)| position) - }); - let Some(position) = position else { - return; - }; - if let Some(current) = store.item(position).and_downcast::() { - store.splice(position, 1, &[current.replacement()]); - } - } - - /// The row the tracker names `key`, and where it is. `None` when it - /// is not on this page. - fn row_for_key(&self, key: &RowKey) -> Option<(u32, RowObject)> { - let store = self.list_store()?; - (0..store.n_items()).find_map(|position| { - let row = store.item(position).and_downcast::()?; - row.id().is_tracked_as(key).then_some((position, row)) - }) - } - /// Scroll to the row behind a failed statement and flash it. A row /// paged, sorted or filtered away is not found, and the alert alone /// tells the user which statement failed. - fn flash_error_row(&self, source: &StatementSource) { + fn flash_error_row(&mut self, source: &StatementSource) { let key = match source { StatementSource::Insert { draft_id } => RowKey::Draft(*draft_id), StatementSource::Update { row_key } | StatementSource::Delete { row_key } => row_key.clone(), }; - let Some((position, row)) = self.row_for_key(&key) else { + let Some(store) = self.list_store() else { + return; + }; + let Some(row) = self.row_index.get(&key) else { + return; + }; + let Some(position) = store.find(&row) else { return; }; if let Some(selection) = self.current_selection.as_ref() { @@ -990,22 +969,17 @@ impl BrowseModel { None, ); } - let generation = self.tracker.flash_error_row(key); - self.refresh_row(&row); + let generation = self.tracker.flash_error_row(key.clone()); + row.bump_revision(); let tracker = self.tracker.clone(); - let selection_for_clear = self.current_selection.clone(); - // Matches the length of the flash animation in the stylesheet. + let tab = self.this.clone(); + // Matches the length of the flash animation in the stylesheet. The + // row is looked up again then, since a reload may have replaced it. glib::timeout_add_local_once(std::time::Duration::from_millis(1800), move || { - if !tracker.clear_error_row_if(generation) { - return; - } - if let Some(selection) = selection_for_clear - && let Some(model) = selection.model() - && let Some(row_obj) = model.item(position).and_then(|o| o.downcast::().ok()) - && let Some(store) = model.downcast::().ok() - && let Some(store_pos) = store.find(&row_obj) + if tracker.clear_error_row_if(generation) + && let Some(tab) = tab.upgrade() { - store.items_changed(store_pos, 1, 1); + tab.send(BrowseTabInput::ErrorFlashEnded(key)); } }); } @@ -1076,11 +1050,14 @@ impl BrowseModel { /// Select the row captured before the reload again, if it is still on /// the page. The capture is used up either way, so it cannot steer a /// later reload. - fn restore_focused_row(&self) { + fn restore_focused_row(&mut self) { let Some(key) = self.pending_focus_restore.borrow_mut().take() else { return; }; - let Some((position, _)) = self.row_for_key(&key) else { + let Some(store) = self.list_store() else { + return; + }; + let Some(position) = self.row_index.position_in(&store, &key) else { return; }; if let Some(selection) = self.current_selection.as_ref() { @@ -1144,6 +1121,7 @@ impl BrowseModel { // Pending draft rows survive page changes, sort flips and F5: // the tracker holds them, and the rebuilt store would not. self.reprepend_drafts(); + self.reindex_rows(); let scrolled = gtk::ScrolledWindow::builder() .child(&column_view) @@ -1162,12 +1140,20 @@ impl BrowseModel { /// New rows under the same columns. The drafts go back on top so /// they survive the swap. - fn refresh_grid_data(&self, result: &QueryResult, store: >k::gio::ListStore) { + fn refresh_grid_data(&mut self, result: &QueryResult, store: >k::gio::ListStore) { store.remove_all(); for row in self.page_rows(result) { store.append(&row); } self.reprepend_drafts(); + self.reindex_rows(); + } + + fn reindex_rows(&mut self) { + match self.list_store() { + Some(store) => self.row_index.rebuild(&store), + None => self.row_index = GridRowIndex::default(), + } } /// Paginator label, button sensitivity and stack child: chrome that @@ -1412,7 +1398,7 @@ impl BrowseModel { BrowseTabInput::Refresh => self.reload(), BrowseTabInput::DiscardAndRefresh => { let delta = self.tracker.discard(); - self.apply_tracker_delta(delta, fx); + self.apply_tracker_delta(&delta, fx); self.reload(); } BrowseTabInput::ClearSelection => { @@ -1543,8 +1529,9 @@ impl BrowseModel { } BrowseTabInput::DeleteSelectedRow => self.delete_selected_rows(fx), BrowseTabInput::GridCellEdited { row, column, input } => { + // The cell shows what was typed until its row is drawn again. if self.refuses_edit(&row, column) { - self.refresh_row(&row); + row.bump_revision(); return; } // A column that holds line breaks keeps them; everywhere @@ -1561,10 +1548,8 @@ impl BrowseModel { let new = match parse_input_for_column(&normalized, col, Self::edit_context_for(&row)) { Ok(v) => v, Err(message) => { - // The cell still shows what was typed; a rebind - // puts the value back. fx.output(BrowseTabOutput::ShowToast(message)); - self.refresh_row(&row); + row.bump_revision(); return; } }; @@ -1643,7 +1628,7 @@ impl BrowseModel { BrowseTabInput::StopSaving => fx.output(BrowseTabOutput::StopSaveRequested), BrowseTabInput::DiscardAll => { let delta = self.tracker.discard(); - self.apply_tracker_delta(delta, fx); + self.apply_tracker_delta(&delta, fx); self.fetch(BrowseRequestKind::Page, fx); } BrowseTabInput::MarkRowsDeleted { rows, drafts } => { @@ -1689,15 +1674,20 @@ impl BrowseModel { }); } BrowseTabInput::FlashErrorRow(source) => self.flash_error_row(&source), + BrowseTabInput::ErrorFlashEnded(key) => { + if let Some(row) = self.row_index.get(&key) { + row.bump_revision(); + } + } BrowseTabInput::FocusInsertedDraft => self.focus_inserted_draft(), BrowseTabInput::Undo => { if let Some(delta) = self.tracker.undo() { - self.apply_tracker_delta(delta, fx); + self.apply_tracker_delta(&delta, fx); } } BrowseTabInput::Redo => { if let Some(delta) = self.tracker.redo() { - self.apply_tracker_delta(delta, fx); + self.apply_tracker_delta(&delta, fx); } } } @@ -1864,21 +1854,19 @@ fn qualified(schema: Option<&str>, table: &str) -> String { } } -fn apply_visual_change(store: >k::gio::ListStore, change: &VisualChange) { +fn apply_visual_change(store: >k::gio::ListStore, index: &mut GridRowIndex, change: &VisualChange) { match change { VisualChange::InsertDraft { draft_id } => { - store.insert(0, &RowObject::draft(*draft_id)); + let row = RowObject::draft(*draft_id); + store.insert(0, &row); + index.insert(&row); } VisualChange::RemoveDraft { draft_id } => { - let position = (0..store.n_items()).find(|position| { - store - .item(*position) - .and_downcast::() - .is_some_and(|row| row.draft_id() == Some(*draft_id)) - }); - if let Some(position) = position { + let key = RowKey::Draft(*draft_id); + if let Some(position) = index.position_in(store, &key) { store.remove(position); } + index.remove(&key); } } } diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 1768bf8176..d4417b4ac8 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -12,14 +12,15 @@ use tablepro_session::fakes::FakeEstablish; use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeReply}; use super::{ - BrowseTab, BrowseTabInit, BrowseTabInput, apply_visual_change, format_thousands, parse_input_for_column, + BrowseTab, BrowseTabInit, BrowseTabInput, Effects, apply_visual_change, format_thousands, parse_input_for_column, persisted_key, }; -use crate::services::change_tracker::RowKey; +use crate::services::change_tracker::{RowKey, StatementSource}; use crate::services::tab_tracker_handle::TabTrackerHandle; -use crate::services::tracker_delta::VisualChange; +use crate::services::tracker_delta::{TrackerDelta, VisualChange}; +use crate::services::tracker_edit::TrackerEdit; use crate::test_support::{FakeSession, drain_main_context, wait_until}; -use crate::ui::grid::RowObject; +use crate::ui::grid::{GridCell, GridRowIndex, RowObject}; const WAIT: Duration = Duration::from_secs(10); @@ -264,13 +265,16 @@ fn row_at(store: >k4::gio::ListStore, position: u32) -> RowObject { fn visual_changes_add_and_remove_draft_rows() { let store = gtk4::gio::ListStore::new::(); store.append(&RowObject::persisted(None, vec![Value::Int(1)].into())); + let mut index = GridRowIndex::default(); - apply_visual_change(&store, &VisualChange::InsertDraft { draft_id: 4 }); + apply_visual_change(&store, &mut index, &VisualChange::InsertDraft { draft_id: 4 }); assert_eq!(row_at(&store, 0).draft_id(), Some(4)); + assert_eq!(index.get(&RowKey::Draft(4)), Some(row_at(&store, 0))); - apply_visual_change(&store, &VisualChange::RemoveDraft { draft_id: 4 }); + apply_visual_change(&store, &mut index, &VisualChange::RemoveDraft { draft_id: 4 }); assert_eq!(store.n_items(), 1); assert_eq!(row_at(&store, 0).draft_id(), None); + assert_eq!(index.get(&RowKey::Draft(4)), None); } fn column(name: &str, primary_key: bool) -> ColumnInfo { @@ -379,13 +383,57 @@ impl Harness { /// The row the grid shows at `position`, drafts included. fn grid_row(&self, position: u32) -> RowObject { + self.selection() + .item(position) + .and_downcast::() + .expect("a row") + } + + fn selection(&self) -> gtk4::MultiSelection { use gtk4::subclass::prelude::ObjectSubclassIsExt; - let model = self.tab().imp().model.borrow(); - let selection = model + self.tab() + .imp() + .model + .borrow() .as_ref() .and_then(|model| model.current_selection.clone()) - .expect("the grid is built"); - selection.item(position).and_downcast::().expect("a row") + .expect("the grid is built") + } + + fn store(&self) -> gtk4::gio::ListStore { + self.selection() + .model() + .and_downcast::() + .expect("a list store") + } + + /// What the tab does with the delta of an edit it made itself. + fn apply_delta(&self, delta: &TrackerDelta) { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let mut effects = Effects::default(); + if let Some(model) = self.tab().imp().model.borrow_mut().as_mut() { + model.apply_tracker_delta(delta, &mut effects); + } + } +} + +/// The tab in a window, so its grid draws cells. +struct Shown(gtk4::Window); + +impl Shown { + fn new(tab: &BrowseTab) -> Self { + let window = gtk4::Window::new(); + window.set_default_size(600, 400); + window.set_child(Some(tab)); + window.present(); + Self(window) + } +} + +impl Drop for Shown { + fn drop(&mut self) { + self.0.set_child(None::<>k4::Widget>); + self.0.destroy(); } } @@ -902,13 +950,42 @@ fn copy_row_as_insert_uses_row_below_draft() { ); } -/// The cell showing `column` of the saved row keyed `id`. -fn grid_cell_of(tab: &BrowseTab, id: i64, column: usize) -> Option { - let key = RowKey::from_pk_values(&[Value::Int(id)])?; - crate::test_support::descendants(tab) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|cell| cell.column() == column && cell.row().is_some_and(|row| row.key() == Some(&key))) +fn key(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") +} + +/// The cell showing `column` of the saved row keyed `id`, once drawn. +fn grid_cell_of(tab: &BrowseTab, id: i64, column: usize) -> GridCell { + let key = key(id); + let mut found = None; + wait_until(WAIT, || { + found = crate::test_support::descendants(tab) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.column() == column && cell.row().is_some_and(|row| row.key() == Some(&key))); + found.is_some() + }) + .expect("the row's cell is drawn"); + found.expect("a cell") +} + +fn cell_text(cell: &GridCell) -> String { + cell.editor() + .map(|editor| editor.text().to_string()) + .unwrap_or_default() +} + +fn has_class(cell: &GridCell, class: &str) -> bool { + cell.content().is_some_and(|content| content.has_css_class(class)) +} + +fn rename(id: i64, from: &str, to: &str) -> TrackerEdit { + TrackerEdit::Cell { + row_key: key(id), + col: 1, + original: Value::Text(from.to_owned()), + value: Value::Text(to.to_owned()), + } } #[gtk4::test] @@ -917,17 +994,8 @@ fn a_commit_leaves_its_cell_in_the_window_for_the_tab_that_follows() { script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); let harness = Harness::open(script, []); wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - let window = gtk4::Window::new(); - window.set_default_size(600, 400); - window.set_child(Some(harness.tab())); - window.present(); - let mut found = None; - wait_until(WAIT, || { - found = grid_cell_of(harness.tab(), 0, 1); - found.is_some() - }) - .expect("Ada's name cell is drawn"); - let cell = found.expect("a cell"); + let _shown = Shown::new(harness.tab()); + let cell = grid_cell_of(harness.tab(), 0, 1); crate::ui::grid::enter_edit_mode(&cell); let editor = cell.editor().expect("a text cell"); editor.entry().set_text("Ada Lovelace"); @@ -939,8 +1007,114 @@ fn a_commit_leaves_its_cell_in_the_window_for_the_tab_that_follows() { "the commit took its own cell out of the window before Tab could move on" ); wait_until(WAIT, || harness.tracker.pending_count() == 1).expect("the edit reaches the tracker"); - window.set_child(None::<>k4::Widget>); - window.destroy(); + assert_eq!(cell_text(&cell), "Ada Lovelace"); + assert!( + has_class(&cell, "tp-cell-modified"), + "the cell does not show the edit as pending" + ); +} + +#[gtk4::test] +fn tracked_edit_keeps_multi_row_selection() { + let mut script = serving_table(); + script + .query_bound + .always(FakeReply::ok(rows(&["Ada", "Grace", "Linus", "Ken"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 4).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let cell = grid_cell_of(harness.tab(), 2, 1); + let selection = harness.selection(); + selection.select_range(1, 3, true); + + let delta = harness.tracker.apply(rename(2, "Linus", "Linus Torvalds")); + harness.apply_delta(&delta); + + assert_eq!( + crate::ui::grid::selected_positions(&selection), + vec![1, 2, 3], + "the edited row left the selection" + ); + assert_eq!(cell_text(&cell), "Linus Torvalds"); + assert!(has_class(&cell, "tp-cell-modified")); +} + +#[gtk4::test] +fn tracked_edit_keeps_row_object_identity() { + let mut script = serving_table(); + script + .query_bound + .always(FakeReply::ok(rows(&["Ada", "Grace", "Linus"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 3).expect("the first page"); + let store = harness.store(); + let before = store.item(2); + let changes = std::rc::Rc::new(std::cell::Cell::new(0)); + let counter = changes.clone(); + store.connect_items_changed(move |_, _, _, _| counter.set(counter.get() + 1)); + + let delta = harness.tracker.apply(rename(2, "Linus", "Linus Torvalds")); + harness.apply_delta(&delta); + + assert!(before.is_some()); + assert_eq!(store.item(2), before, "the edited row was swapped for another object"); + assert_eq!(changes.get(), 0, "the store announced a change to its rows"); +} + +#[gtk4::test] +fn rejected_edit_restores_cell_display() { + let mut age = col("integer", true); + age.name = "age".to_owned(); + let columns = vec![column("id", true), age]; + let mut script = serving_table(); + script.fetch_columns.always(FakeReply::ok(columns.clone())); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(0), Value::Int(36)]], + ))); + let harness = Harness::open(script, []); + wait_until(WAIT, || { + harness.tab().snapshot().is_some_and(|page| page.rows.len() == 1) + }) + .expect("the first page"); + let toasts = toasts(harness.tab()); + let _shown = Shown::new(harness.tab()); + let cell = grid_cell_of(harness.tab(), 0, 1); + crate::ui::grid::enter_edit_mode(&cell); + let editor = cell.editor().expect("a text cell"); + editor.entry().set_text("thirty-six"); + + editor.stop_editing(true); + + wait_until(WAIT, || cell_text(&cell) == "36").expect("the cell kept the text that was refused"); + assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); + assert_eq!(harness.tracker.pending_count(), 0); +} + +#[gtk4::test] +fn error_flash_sets_and_clears_on_the_failed_row() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let cell = grid_cell_of(harness.tab(), 1, 0); + + harness + .tab() + .send(BrowseTabInput::FlashErrorRow(StatementSource::Update { + row_key: key(1), + })); + + assert!( + has_class(&cell, "tp-row-leftmost-error-flash"), + "the failed row did not flash" + ); + assert!(!has_class( + &grid_cell_of(harness.tab(), 0, 0), + "tp-row-leftmost-error-flash" + )); + wait_until(WAIT, || !has_class(&cell, "tp-row-leftmost-error-flash")).expect("the flash never ended"); } #[gtk4::test] diff --git a/linux/crates/app/src/ui/grid/cell_binding.rs b/linux/crates/app/src/ui/grid/cell_binding.rs new file mode 100644 index 0000000000..fe124c0ba5 --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_binding.rs @@ -0,0 +1,39 @@ +use gtk4::glib; +use gtk4::prelude::*; + +use super::{GridCell, RowObject}; + +/// A cell's hold on the row it shows. Dropping it disconnects the cell +/// from the row's revision, so a recycled cell never draws for a row it +/// left. +#[derive(Debug)] +pub struct CellBinding { + row: RowObject, + handler: Option, +} + +impl CellBinding { + pub fn new(cell: &GridCell, row: &RowObject) -> Self { + let handler = row.connect_revision_notify(glib::clone!( + #[weak] + cell, + move |_| cell.render() + )); + Self { + row: row.clone(), + handler: Some(handler), + } + } + + pub fn row(&self) -> &RowObject { + &self.row + } +} + +impl Drop for CellBinding { + fn drop(&mut self) { + if let Some(handler) = self.handler.take() { + self.row.disconnect(handler); + } + } +} diff --git a/linux/crates/app/src/ui/grid/cell_editor.rs b/linux/crates/app/src/ui/grid/cell_editor.rs index 16641f8fba..5392905d89 100644 --- a/linux/crates/app/src/ui/grid/cell_editor.rs +++ b/linux/crates/app/src/ui/grid/cell_editor.rs @@ -128,9 +128,13 @@ impl CellEditor { } } + /// An edit in progress keeps what was typed; a row redrawn under it + /// changes what Escape goes back to. pub fn set_text(&self, s: &str) { self.label_widget().set_text(s); - self.entry_widget().set_text(s); + if !self.is_editing() { + self.entry_widget().set_text(s); + } } pub fn set_selectable(&self, selectable: bool) { diff --git a/linux/crates/app/src/ui/grid/column_drawing.rs b/linux/crates/app/src/ui/grid/column_drawing.rs new file mode 100644 index 0000000000..62548c2613 --- /dev/null +++ b/linux/crates/app/src/ui/grid/column_drawing.rs @@ -0,0 +1,134 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +use tablepro_core::{ColumnInfo, Value}; + +use super::display_text::{auto_filled_sentinel, editable_null_sentinel, value_to_display_text, value_to_edit_text}; +use super::{CellEditor, CellLock, GridCell, GridRowId, RowObject, TabGridContext, cell_editability}; + +const PENDING_CSS_CLASSES: &[&str] = &[ + "tp-cell-modified", + "tp-row-pending-delete", + "tp-row-pending-insert", + "tp-row-leftmost-error-flash", +]; + +/// Shorter text fits a typical 200 px column, so its ellipsized label +/// needs no tooltip. +const TOOLTIP_MIN_CHARS: usize = 40; + +/// What a column's cells read to draw a row. The column makes one and +/// every cell it builds shares it. +#[derive(Debug)] +pub struct ColumnDrawing { + /// `None` for a column the grid knows only from the result set. + pub catalog: Option, + pub tab_ctx: TabGridContext, + /// The server fills these in, so a draft row shows a placeholder + /// rather than an empty cell the user should type in. + pub auto_filled: bool, +} + +impl ColumnDrawing { + pub fn lock(&self, row: &GridRowId) -> Option { + match self.catalog.as_ref() { + Some(column) => cell_editability(column, row, &self.tab_ctx.identity, !self.tab_ctx.editable).err(), + None => Some(CellLock::ResultRow), + } + } + + /// The value as the tracker has it, pending edits and state included. + pub fn draw(&self, cell: &GridCell, row: &RowObject) { + let (value, pending_classes) = self.tab_ctx.bind_state(row, cell.column()); + draw_cell(cell, &value, &pending_classes, cell.lock().is_none(), self.auto_filled); + } +} + +/// `editable` is whether this row's cell takes an edit: such a cell +/// shows NULL as ``, so it reads differently from an empty +/// string, and a locked one dims a plain NULL. +fn draw_cell(grid_cell: &GridCell, value: &Value, pending_classes: &[&'static str], editable: bool, auto_filled: bool) { + let is_null = matches!(value, Value::Null); + let text = if is_null && auto_filled { + auto_filled_sentinel() + } else if editable { + if is_null { + editable_null_sentinel() + } else { + value_to_edit_text(value) + } + } else { + value_to_display_text(value) + }; + let is_pending_delete = pending_classes.contains(&"tp-row-pending-delete"); + let Some(content) = grid_cell.content() else { + return; + }; + if let Some(editor) = content.downcast_ref::() { + editor.set_text(&text); + // A locked cell's text is there to be read and copied, as a + // read-only column's is. + editor.set_selectable(!editable); + apply_cell_tooltip(content, &text, is_null); + set_class(content, "dim-label", is_null && !editable); + set_class(content, "tp-null-sentinel", is_null && (editable || auto_filled)); + apply_pending_classes(content, pending_classes); + editor.set_strikethrough(is_pending_delete); + } else if let Some(checkbox) = content.downcast_ref::() { + // Suppressed, so drawing the value does not echo back as an edit. + grid_cell.suppress_toggle(true); + // A value that is not a bool shows as inconsistent rather than as + // a confidently wrong check box. + let (active, inconsistent) = match value { + Value::Bool(flag) => (*flag, false), + _ => (false, true), + }; + checkbox.set_inconsistent(inconsistent); + checkbox.set_active(active); + grid_cell.suppress_toggle(false); + checkbox.set_sensitive(editable); + apply_pending_classes(content, pending_classes); + // A check box has no strikethrough, so a pending delete dims it. + checkbox.set_opacity(if is_pending_delete { 0.5 } else { 1.0 }); + } else if let Some(label) = content.downcast_ref::() { + label.set_text(&text); + apply_cell_tooltip(content, &text, is_null); + set_class(content, "dim-label", is_null); + apply_pending_classes(content, pending_classes); + set_label_strikethrough(label, is_pending_delete); + } +} + +fn set_class(widget: >k::Widget, class: &str, on: bool) { + if on { + widget.add_css_class(class); + } else { + widget.remove_css_class(class); + } +} + +fn apply_pending_classes(widget: >k::Widget, pending_classes: &[&'static str]) { + for class in PENDING_CSS_CLASSES { + set_class(widget, class, pending_classes.contains(class)); + } +} + +fn apply_cell_tooltip(widget: >k::Widget, text: &str, is_null: bool) { + if is_null || text.chars().take(TOOLTIP_MIN_CHARS + 1).count() <= TOOLTIP_MIN_CHARS { + widget.set_tooltip_text(None); + } else { + widget.set_tooltip_text(Some(text)); + } +} + +/// A Pango attribute, because CSS `text-decoration` does not cascade +/// reliably into a cell's label. +fn set_label_strikethrough(label: >k::Label, on: bool) { + if on { + let attrs = gtk::pango::AttrList::new(); + attrs.insert(gtk::pango::AttrInt::new_strikethrough(true)); + label.set_attributes(Some(&attrs)); + } else { + label.set_attributes(None); + } +} diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index 8998255428..7b5e7b200b 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -1,29 +1,18 @@ +use std::rc::Rc; + use gtk4::prelude::*; use gtk4::{self as gtk, glib}; use tablepro_core::column::ResultColumn; -use tablepro_core::{ColumnInfo, QueryResult, Value}; +use tablepro_core::{ColumnInfo, QueryResult}; use super::cell_editability::column_takes_edits; use super::cell_editor_kind::{CellEditorKind, classify_editor_kind, is_bool_type}; +use super::column_drawing::ColumnDrawing; use super::context_menu::{CellMenuTarget, attach_cell_gesture}; -use super::display_text::{auto_filled_sentinel, editable_null_sentinel, value_to_display_text, value_to_edit_text}; use super::grid_menus::{GridMenuInit, GridMenus, install_grid_context_menus}; use super::inline_edit::{install_edit_commit_handler, install_edit_triggers}; -use super::{ - CellEditor, CellLock, GridCell, GridMsg, GridRowId, GridSink, RowObject, TabGridContext, cell_editability, -}; - -const PENDING_CSS_CLASSES: &[&str] = &[ - "tp-cell-modified", - "tp-row-pending-delete", - "tp-row-pending-insert", - "tp-row-leftmost-error-flash", -]; - -/// Shorter text fits a typical 200 px column, so its ellipsized label -/// needs no tooltip. -const TOOLTIP_MIN_CHARS: usize = 40; +use super::{CellEditor, GridCell, GridMsg, GridSink, RowObject, TabGridContext}; const WIDE_TABLE_THRESHOLD: usize = 8; /// About 14 characters at the default font size. @@ -173,11 +162,14 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { } _ => CellKind::ReadOnly, }; - // The server fills these in, so a draft row shows a placeholder - // rather than an empty cell the user should type in. let auto_filled = catalog .as_ref() .is_some_and(|column| column.is_auto_increment || column.is_generated); + let drawing = Rc::new(ColumnDrawing { + catalog, + tab_ctx: tab_ctx.clone(), + auto_filled, + }); let target = CellMenuTarget { text_editable: matches!(kind, CellKind::Text(_)), accepts_empty: matches!(kind, CellKind::Text(_)) && info.column_type.kind.accepts_empty_string(), @@ -189,6 +181,7 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { }; let grid_cell = GridCell::new(); grid_cell.attach(cell, idx); + grid_cell.set_drawing(drawing.clone()); match kind { CellKind::ReadOnly => setup_readonly_cell(&grid_cell), CellKind::Toggle => setup_toggle_cell(&grid_cell, sink.clone()), @@ -198,40 +191,26 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { cell.set_child(Some(&grid_cell)); }); - let tab_ctx_for_bind = tab_ctx.clone(); - factory.connect_bind(move |_, item| { + factory.connect_bind(|_, item| { let Some(cell) = item.downcast_ref::() else { return; }; - let (Some(row), Some(grid_cell)) = ( + if let (Some(row), Some(grid_cell)) = ( cell.item().and_downcast::(), cell.child().and_downcast::(), - ) else { - return; - }; - let lock = cell_lock(catalog.as_ref(), row.id(), &tab_ctx_for_bind); - grid_cell.set_lock(lock); - let (value, pending_classes) = tab_ctx_for_bind.bind_state(&row, idx); - draw_cell(&grid_cell, &value, &pending_classes, lock.is_none(), auto_filled); + ) { + grid_cell.bind(&row); + } }); factory.connect_unbind(|_, item| { - let Some(grid_cell) = item + if let Some(grid_cell) = item .downcast_ref::() .and_then(|cell| cell.child()) .and_downcast::() - else { - return; - }; - if let Some(editor) = grid_cell.editor() - && editor.is_editing() { - editor.stop_editing(false); + grid_cell.unbind(); } - if let Some(popover) = grid_cell.take_popover() { - popover.popdown(); - } - grid_cell.take_edit(); }); let column = gtk::ColumnViewColumn::builder() @@ -261,68 +240,6 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { column } -fn cell_lock(catalog: Option<&ColumnInfo>, row: &GridRowId, tab_ctx: &TabGridContext) -> Option { - match catalog { - Some(column) => cell_editability(column, row, &tab_ctx.identity, !tab_ctx.editable).err(), - None => Some(CellLock::ResultRow), - } -} - -/// `editable` is whether this row's cell takes an edit: such a cell -/// shows NULL as ``, so it reads differently from an empty -/// string, and a locked one dims a plain NULL. -fn draw_cell(grid_cell: &GridCell, value: &Value, pending_classes: &[&'static str], editable: bool, auto_filled: bool) { - let is_null = matches!(value, Value::Null); - let text = if is_null && auto_filled { - auto_filled_sentinel() - } else if editable { - if is_null { - editable_null_sentinel() - } else { - value_to_edit_text(value) - } - } else { - value_to_display_text(value) - }; - let is_pending_delete = pending_classes.contains(&"tp-row-pending-delete"); - let Some(content) = grid_cell.content() else { - return; - }; - if let Some(editor) = content.downcast_ref::() { - editor.set_text(&text); - // A locked cell's text is there to be read and copied, as a - // read-only column's is. - editor.set_selectable(!editable); - apply_cell_tooltip(content, &text, is_null); - set_class(content, "dim-label", is_null && !editable); - set_class(content, "tp-null-sentinel", is_null && (editable || auto_filled)); - apply_pending_classes(content, pending_classes); - editor.set_strikethrough(is_pending_delete); - } else if let Some(checkbox) = content.downcast_ref::() { - // Suppressed, so drawing the value does not echo back as an edit. - grid_cell.suppress_toggle(true); - // A value that is not a bool shows as inconsistent rather than as - // a confidently wrong check box. - let (active, inconsistent) = match value { - Value::Bool(flag) => (*flag, false), - _ => (false, true), - }; - checkbox.set_inconsistent(inconsistent); - checkbox.set_active(active); - grid_cell.suppress_toggle(false); - checkbox.set_sensitive(editable); - apply_pending_classes(content, pending_classes); - // A check box has no strikethrough, so a pending delete dims it. - checkbox.set_opacity(if is_pending_delete { 0.5 } else { 1.0 }); - } else if let Some(label) = content.downcast_ref::() { - label.set_text(&text); - apply_cell_tooltip(content, &text, is_null); - set_class(content, "dim-label", is_null); - apply_pending_classes(content, pending_classes); - set_label_strikethrough(label, is_pending_delete); - } -} - fn setup_editable_cell(grid_cell: &GridCell, sink: GridSink, editor_kind: CellEditorKind) { let editor = CellEditor::new(); editor.set_hexpand(true); @@ -374,43 +291,6 @@ fn setup_readonly_cell(grid_cell: &GridCell) { grid_cell.set_content(&label); } -fn set_class(widget: >k::Widget, class: &str, on: bool) { - if on { - widget.add_css_class(class); - } else { - widget.remove_css_class(class); - } -} - -fn apply_pending_classes(widget: >k::Widget, pending_classes: &[&'static str]) { - for cls in PENDING_CSS_CLASSES { - widget.remove_css_class(cls); - } - for cls in pending_classes { - widget.add_css_class(cls); - } -} - -fn apply_cell_tooltip(widget: >k::Widget, text: &str, is_null: bool) { - if is_null || text.chars().take(TOOLTIP_MIN_CHARS + 1).count() <= TOOLTIP_MIN_CHARS { - widget.set_tooltip_text(None); - } else { - widget.set_tooltip_text(Some(text)); - } -} - -/// A Pango attribute, because CSS `text-decoration` does not cascade -/// reliably into a cell's label. -fn set_label_strikethrough(label: >k::Label, on: bool) { - if on { - let attrs = gtk::pango::AttrList::new(); - attrs.insert(gtk::pango::AttrInt::new_strikethrough(true)); - label.set_attributes(Some(&attrs)); - } else { - label.set_attributes(None); - } -} - #[cfg(test)] mod tests { use std::cell::RefCell; @@ -762,29 +642,104 @@ mod tests { } #[gtk4::test] - fn edit_during_rebind_shows_pending_value() { + fn a_revision_bump_draws_the_pending_value_in_the_same_cell() { let grid = two_text_rows(); - let texts = || -> Vec { - descendants(&grid.view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .map(|cell| cell.text().to_string()) - .collect() - }; - + let cell = grid.cell_of(1, 1); let delta = grid.tracker.apply(TrackerEdit::Cell { row_key: key(1), col: 1, original: Value::Text("A".into()), value: Value::Text("new".into()), }); - // A fresh row object, because the list item manager skips the - // rebind for an items-changed that keeps the same item. - let row = grid.store.item(0).and_downcast::().expect("a row"); - grid.store.splice(0, 1, &[row.replacement()]); - wait_until(WAIT, || texts().iter().any(|text| text == "new")).expect("the rebind draws the pending value"); + grid.store + .item(0) + .and_downcast::() + .expect("a row") + .bump_revision(); + assert_eq!(delta.pending_count, 1); + assert_eq!(cell.editor().expect("a text cell").text(), "new"); + assert!( + cell.content() + .is_some_and(|content| content.has_css_class("tp-cell-modified")) + ); + } + + #[gtk4::test] + fn an_edit_in_progress_keeps_its_text_when_the_row_is_drawn_again() { + let grid = two_text_rows(); + let cell = grid.cell_of(2, 1); + let editor = type_into(&cell, "typed"); + let row = cell.row().expect("a bound row"); + + row.bump_revision(); + editor.stop_editing(false); + + assert_eq!(editor.text(), "B", "Escape did not go back to the row's value"); + type_into(&cell, "typed"); + row.bump_revision(); + assert_eq!(editor.entry().text(), "typed", "the redraw overwrote what was typed"); + } + + #[gtk4::test] + fn unbind_disconnects_revision_handler() { + let columns = vec![named("id", "integer", true), named("name", "text", false)]; + let rows: Vec> = (0..300) + .map(|id| vec![Value::Int(id), Value::Text(format!("row {id}"))]) + .collect(); + let result = QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + rows.clone(), + ); + let tracker = TabTrackerHandle::default(); + let (sink, _messages) = recorder(); + let (view, _selection) = build_column_view( + &result, + persisted(&rows), + &columns, + sink, + None, + false, + tracked(&tracker), + ); + let scrolled = gtk::ScrolledWindow::builder().child(&view).build(); + let window = gtk::Window::new(); + window.set_default_size(600, 400); + window.set_child(Some(&scrolled)); + window.present(); + // Not the first row, which the view may keep bound as its focus. + let name_cell = || { + descendants(&view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.column() == 1 && cell.row().is_some_and(|row| row.key() == Some(&key(3)))) + }; + let mut found = None; + wait_until(WAIT, || { + found = name_cell(); + found.is_some() + }) + .expect("the fourth row is drawn"); + let cell = found.expect("a cell"); + let row = cell.row().expect("a bound row"); + let bound_renders = cell.render_count(); + row.bump_revision(); + assert_eq!(cell.render_count(), bound_renders + 1, "a bound cell did not redraw"); + + view.scroll_to(299, None, gtk::ListScrollFlags::NONE, None); + wait_until(WAIT, || cell.row().as_ref() != Some(&row)).expect("the fourth row's cell was never let go"); + let unbound_renders = cell.render_count(); + row.bump_revision(); + drain_main_context(); + + assert_eq!( + cell.render_count(), + unbound_renders, + "a cell drew again for a row it no longer shows" + ); + window.destroy(); + drain_main_context(); } fn width_binding(tasks: &tablepro_session::runtime::Tasks, root: &tempfile::TempDir) -> ColumnWidthBinding { diff --git a/linux/crates/app/src/ui/grid/grid_cell.rs b/linux/crates/app/src/ui/grid/grid_cell.rs index 801cead4ab..4fbb1a5043 100644 --- a/linux/crates/app/src/ui/grid/grid_cell.rs +++ b/linux/crates/app/src/ui/grid/grid_cell.rs @@ -1,9 +1,12 @@ use std::cell::{Cell, OnceCell, RefCell}; +use std::rc::Rc; use gtk4::prelude::*; use gtk4::subclass::prelude::*; use gtk4::{self as gtk, glib}; +use super::cell_binding::CellBinding; +use super::column_drawing::ColumnDrawing; use super::{CellEditor, CellLock, EditSnapshot, RowObject}; mod imp { @@ -26,6 +29,10 @@ mod imp { /// text from before the composition. pub preedit_active: Cell, pub lock: Cell>, + pub drawing: OnceCell>, + pub binding: RefCell>, + #[cfg(test)] + pub renders: Cell, } #[glib::object_subclass] @@ -42,6 +49,7 @@ mod imp { impl ObjectImpl for GridCell { fn dispose(&self) { + self.binding.take(); if let Some(popover) = self.popover.take() { popover.unparent(); } @@ -125,8 +133,55 @@ impl GridCell { self.imp().lock.get() } - pub(super) fn set_lock(&self, lock: Option) { + pub(super) fn set_drawing(&self, drawing: Rc) { + let _ = self.imp().drawing.set(drawing); + } + + /// Show `row`, and draw it again each time its revision moves. + pub(super) fn bind(&self, row: &RowObject) { + let lock = match self.imp().drawing.get() { + Some(drawing) => drawing.lock(row.id()), + None => Some(CellLock::ResultRow), + }; self.imp().lock.set(lock); + self.imp().binding.replace(Some(CellBinding::new(self, row))); + self.render(); + } + + /// An edit or popover still open is cancelled, since the row it was + /// for is no longer this cell's. + pub(super) fn unbind(&self) { + self.imp().binding.take(); + if let Some(editor) = self.editor() + && editor.is_editing() + { + editor.stop_editing(false); + } + if let Some(popover) = self.take_popover() { + popover.popdown(); + } + self.take_edit(); + } + + /// Draw the bound row as the tracker has it now. + pub(super) fn render(&self) { + #[cfg(test)] + self.imp().renders.set(self.imp().renders.get() + 1); + let row = self + .imp() + .binding + .borrow() + .as_ref() + .map(|binding| binding.row().clone()); + let (Some(row), Some(drawing)) = (row, self.imp().drawing.get().cloned()) else { + return; + }; + drawing.draw(self, &row); + } + + #[cfg(test)] + pub(super) fn render_count(&self) -> u32 { + self.imp().renders.get() } pub(super) fn begin_edit(&self, snapshot: EditSnapshot) { diff --git a/linux/crates/app/src/ui/grid/grid_row_id.rs b/linux/crates/app/src/ui/grid/grid_row_id.rs index 1abd49557b..f7c10011c1 100644 --- a/linux/crates/app/src/ui/grid/grid_row_id.rs +++ b/linux/crates/app/src/ui/grid/grid_row_id.rs @@ -38,15 +38,6 @@ impl GridRowId { Self::Result | Self::Unkeyed => None, } } - - /// Whether the tracker's `key` is this row, without building one. - pub fn is_tracked_as(&self, key: &RowKey) -> bool { - match (self, key) { - (Self::Persisted(own), _) => own == key, - (Self::Draft(own), RowKey::Draft(other)) => own == other, - _ => false, - } - } } #[cfg(test)] @@ -67,15 +58,4 @@ mod tests { assert_eq!(GridRowId::Draft(3).tracker_key(), Some(RowKey::Draft(3))); assert_eq!(GridRowId::Result.tracker_key(), None); } - - #[test] - fn a_draft_is_never_taken_for_a_persisted_row() { - let key = RowKey::from_pk_values(&[Value::Int(3)]).expect("a key"); - - assert!(GridRowId::Draft(3).is_tracked_as(&RowKey::Draft(3))); - assert!(!GridRowId::Draft(3).is_tracked_as(&key)); - assert!(GridRowId::Persisted(key.clone()).is_tracked_as(&key)); - assert!(!GridRowId::Persisted(key).is_tracked_as(&RowKey::Draft(3))); - assert!(!GridRowId::Unkeyed.is_tracked_as(&RowKey::Draft(3))); - } } diff --git a/linux/crates/app/src/ui/grid/grid_row_index.rs b/linux/crates/app/src/ui/grid/grid_row_index.rs new file mode 100644 index 0000000000..54b6019a00 --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_row_index.rs @@ -0,0 +1,111 @@ +use std::collections::HashMap; + +use gtk4::gio; +use gtk4::glib; +use gtk4::prelude::*; + +use super::RowObject; +use crate::services::change_tracker::RowKey; + +/// The grid's rows by the key the tracker files their changes under, +/// drafts included. The store owns the rows, so a row it drops is +/// forgotten here on the next lookup. +#[derive(Debug, Default)] +pub struct GridRowIndex(HashMap>); + +impl GridRowIndex { + pub fn rebuild(&mut self, store: &gio::ListStore) { + self.0.clear(); + for position in 0..store.n_items() { + if let Some(row) = store.item(position).and_downcast::() { + self.insert(&row); + } + } + } + + pub fn insert(&mut self, row: &RowObject) { + if let Some(key) = row.id().tracker_key() { + self.0.insert(key, row.downgrade()); + } + } + + pub fn remove(&mut self, key: &RowKey) { + self.0.remove(key); + } + + pub fn get(&mut self, key: &RowKey) -> Option { + let row = self.0.get(key)?.upgrade(); + if row.is_none() { + self.0.remove(key); + } + row + } + + pub fn position_in(&mut self, store: &gio::ListStore, key: &RowKey) -> Option { + store.find(&self.get(key)?) + } + + #[cfg(test)] + fn len(&self) -> usize { + self.0.len() + } +} + +#[cfg(test)] +mod tests { + use gtk4::gio; + use tablepro_core::Value; + + use super::GridRowIndex; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::RowObject; + + fn key(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") + } + + fn saved(id: i64) -> RowObject { + RowObject::persisted(Some(key(id)), vec![Value::Int(id)].into()) + } + + #[test] + fn grid_row_index_drops_dead_rows_on_get() { + let mut index = GridRowIndex::default(); + let kept = saved(1); + index.insert(&kept); + index.insert(&saved(2)); + + assert_eq!(index.get(&key(1)), Some(kept)); + assert_eq!(index.get(&key(2)), None, "a row nothing holds came back"); + assert_eq!(index.len(), 1, "the dead row stayed in the index"); + } + + #[test] + fn drafts_are_found_by_their_draft_key_and_unkeyed_rows_not_at_all() { + let store = gio::ListStore::new::(); + store.append(&RowObject::draft(7)); + store.append(&saved(1)); + store.append(&RowObject::persisted(None, vec![Value::Int(3)].into())); + let mut index = GridRowIndex::default(); + + index.rebuild(&store); + + assert_eq!(index.position_in(&store, &RowKey::Draft(7)), Some(0)); + assert_eq!(index.position_in(&store, &key(1)), Some(1)); + assert_eq!(index.len(), 2); + index.remove(&RowKey::Draft(7)); + assert_eq!(index.get(&RowKey::Draft(7)), None); + } + + #[test] + fn a_draft_is_never_taken_for_a_saved_row_with_the_same_number() { + let mut index = GridRowIndex::default(); + let draft = RowObject::draft(3); + let row = saved(3); + index.insert(&draft); + index.insert(&row); + + assert_eq!(index.get(&RowKey::Draft(3)), Some(draft)); + assert_eq!(index.get(&key(3)), Some(row)); + } +} diff --git a/linux/crates/app/src/ui/grid/mod.rs b/linux/crates/app/src/ui/grid/mod.rs index 82ab20fd7b..6b41ae1917 100644 --- a/linux/crates/app/src/ui/grid/mod.rs +++ b/linux/crates/app/src/ui/grid/mod.rs @@ -1,9 +1,11 @@ +mod cell_binding; mod cell_editability; mod cell_editor; mod cell_editor_kind; mod cell_lock; mod cell_popovers; mod cell_preset; +mod column_drawing; mod column_factory; mod column_width_binding; mod context_menu; @@ -13,6 +15,7 @@ mod grid_cell; mod grid_menus; mod grid_msg; mod grid_row_id; +mod grid_row_index; mod inline_edit; mod row_json_dialog; mod row_object; @@ -29,6 +32,7 @@ pub(crate) use self::edit_snapshot::EditSnapshot; pub(crate) use self::grid_cell::GridCell; pub(crate) use self::grid_msg::{GridMsg, GridSink}; pub(crate) use self::grid_row_id::GridRowId; +pub(crate) use self::grid_row_index::GridRowIndex; pub(crate) use self::inline_edit::enter_edit_mode; pub(crate) use self::row_object::RowObject; pub(crate) use self::selection::{export_snapshot, selected_positions}; diff --git a/linux/crates/app/src/ui/grid/row_object.rs b/linux/crates/app/src/ui/grid/row_object.rs index 657be5b6e0..f0dabb5bb0 100644 --- a/linux/crates/app/src/ui/grid/row_object.rs +++ b/linux/crates/app/src/ui/grid/row_object.rs @@ -1,7 +1,8 @@ -use std::cell::OnceCell; +use std::cell::{Cell, OnceCell}; use std::sync::Arc; use gtk4::glib; +use gtk4::prelude::*; use gtk4::subclass::prelude::*; use tablepro_core::Value; @@ -12,12 +13,17 @@ use crate::services::change_tracker::RowKey; mod imp { use super::*; - #[derive(Default)] + #[derive(Default, glib::Properties)] + #[properties(wrapper_type = super::RowObject)] pub struct RowObject { pub id: OnceCell, /// The row as it was read. A draft has none: its cells are the /// tracker's. pub cells: OnceCell>, + /// Moves on when the row's pending state changes, so the cells + /// bound to it draw again without the store swapping the row. + #[property(get)] + pub revision: Cell, } #[glib::object_subclass] @@ -26,6 +32,7 @@ mod imp { type Type = super::RowObject; } + #[glib::derived_properties] impl ObjectImpl for RowObject {} } @@ -64,13 +71,6 @@ impl RowObject { Self::with(GridRowId::Result, Some(cells)) } - /// A new object for the same row. The list item manager skips the - /// rebind for an items-changed that keeps the same item, so a row - /// whose pending state changed is swapped for one of these. - pub fn replacement(&self) -> Self { - Self::with(self.id().clone(), self.imp().cells.get().cloned()) - } - pub fn id(&self) -> &GridRowId { self.imp().id.get().unwrap_or(&UNNAMED) } @@ -90,10 +90,20 @@ impl RowObject { pub fn cell(&self, column: usize) -> Option<&Value> { self.imp().cells.get()?.get(column) } + + /// Tell the cells bound to this row that its pending state changed. + pub(crate) fn bump_revision(&self) { + let revision = &self.imp().revision; + revision.set(revision.get().wrapping_add(1)); + self.notify_revision(); + } } #[cfg(test)] mod tests { + use std::cell::Cell; + use std::rc::Rc; + use tablepro_core::Value; use super::RowObject; @@ -112,7 +122,19 @@ mod tests { assert_eq!(unkeyed.id(), &GridRowId::Unkeyed); assert_eq!(draft.draft_id(), Some(5)); assert!(draft.cells().is_none(), "a draft's cells are the tracker's"); - assert_eq!(persisted.replacement().id(), persisted.id()); - assert_eq!(persisted.replacement().cells(), persisted.cells()); + } + + #[test] + fn bump_revision_notifies_each_listener_once() { + let row = RowObject::draft(1); + let heard = Rc::new(Cell::new(0)); + let counter = heard.clone(); + row.connect_revision_notify(move |_| counter.set(counter.get() + 1)); + + row.bump_revision(); + row.bump_revision(); + + assert_eq!(row.revision(), 2); + assert_eq!(heard.get(), 2); } } diff --git a/linux/data/resources/style.css b/linux/data/resources/style.css index 3834af2689..37704ef1e3 100644 --- a/linux/data/resources/style.css +++ b/linux/data/resources/style.css @@ -40,8 +40,8 @@ columnview > listview > row > cell:focus-within > tp-grid-cell > .tp-cell-editor border-radius: 2px; } -/* One-shot flash on the row whose statement failed to commit. The bind - callback re-applies the class until the tab clears its error row. */ +/* One-shot flash on the row whose statement failed to commit. Each + redraw of the row keeps the class until the tab clears its error row. */ @keyframes tp-flash-error { 0% { background: color-mix(in srgb, var(--error-bg-color) 55%, transparent); diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 1e717d2e00..b9edcf0774 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -134,12 +134,14 @@ crates/app/src/ui/error_text/tls_remedy.rs crates/app/src/ui/export_dialog.rs crates/app/src/ui/file_read.rs crates/app/src/ui/filter_strip.rs +crates/app/src/ui/grid/cell_binding.rs crates/app/src/ui/grid/cell_editability.rs crates/app/src/ui/grid/cell_editor.rs crates/app/src/ui/grid/cell_editor_kind.rs crates/app/src/ui/grid/cell_lock.rs crates/app/src/ui/grid/cell_popovers.rs crates/app/src/ui/grid/cell_preset.rs +crates/app/src/ui/grid/column_drawing.rs crates/app/src/ui/grid/column_factory.rs crates/app/src/ui/grid/column_width_binding.rs crates/app/src/ui/grid/context_menu.rs @@ -149,6 +151,7 @@ crates/app/src/ui/grid/grid_cell.rs crates/app/src/ui/grid/grid_menus.rs crates/app/src/ui/grid/grid_msg.rs crates/app/src/ui/grid/grid_row_id.rs +crates/app/src/ui/grid/grid_row_index.rs crates/app/src/ui/grid/inline_edit.rs crates/app/src/ui/grid/mod.rs crates/app/src/ui/grid/row_json_dialog.rs From 53d1cb31708f32c2b98ac59e069eedac6d4347b4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:03:43 +0700 Subject: [PATCH 158/178] feat(linux): order column renames and toggle PostgreSQL identity --- linux/Cargo.lock | 1 + linux/crates/app/Cargo.toml | 1 + .../app/src/services/pending_structure.rs | 238 +++++++++++++- linux/crates/app/src/ui/error_text/ddl.rs | 1 + linux/crates/core/src/ddl/ddl_plan.rs | 29 ++ linux/crates/core/src/ddl/mod.rs | 2 + linux/crates/core/src/ddl/order_renames.rs | 233 ++++++++++++++ linux/crates/core/src/ddl/refusal_reason.rs | 3 + linux/crates/core/src/ddl/table_changes.rs | 31 +- linux/crates/core/src/ddl/tests.rs | 66 +++- linux/crates/drivers/clickhouse/src/ddl.rs | 21 +- .../clickhouse/tests/integration/ddl.rs | 70 ++++ .../clickhouse/tests/integration/main.rs | 1 + linux/crates/drivers/mssql/src/ddl.rs | 30 +- .../drivers/mssql/tests/integration/ddl.rs | 201 +++++++++++- linux/crates/drivers/mysql/src/ddl.rs | 47 ++- .../drivers/mysql/tests/integration/ddl.rs | 153 ++++++++- linux/crates/drivers/postgres/src/ddl.rs | 298 ++++++++++++++++-- .../drivers/postgres/tests/integration/ddl.rs | 242 +++++++++++++- linux/crates/drivers/sqlite/src/ddl.rs | 26 +- .../drivers/sqlite/tests/integration/ddl.rs | 113 +++++++ .../drivers/sqlite/tests/integration/main.rs | 2 + 22 files changed, 1742 insertions(+), 67 deletions(-) create mode 100644 linux/crates/core/src/ddl/order_renames.rs create mode 100644 linux/crates/drivers/clickhouse/tests/integration/ddl.rs create mode 100644 linux/crates/drivers/sqlite/tests/integration/ddl.rs diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 5ea44518e5..d2840b74cb 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -4319,6 +4319,7 @@ dependencies = [ "gtk4", "libadwaita", "log", + "proptest", "relm4", "secrecy", "serde", diff --git a/linux/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml index 5550a1b43e..2beab9b40b 100644 --- a/linux/crates/app/Cargo.toml +++ b/linux/crates/app/Cargo.toml @@ -68,6 +68,7 @@ tablepro-session = { path = "../session", features = ["fakes"] } tablepro-test-fixtures = { path = "../test-fixtures" } tablepro-test-support = { path = "../test-support" } tokio = { workspace = true, features = ["macros", "rt", "test-util"] } +proptest.workspace = true tempfile.workspace = true # tokio-postgres logs through `log`, so the logging tests drive that # bridge the way it does. diff --git a/linux/crates/app/src/services/pending_structure.rs b/linux/crates/app/src/services/pending_structure.rs index b45c250b47..773da6b9e3 100644 --- a/linux/crates/app/src/services/pending_structure.rs +++ b/linux/crates/app/src/services/pending_structure.rs @@ -91,9 +91,12 @@ impl PendingStructure { #[cfg(test)] mod tests { + use proptest::prelude::*; use tablepro_core::ServerInfo; use tablepro_core::column::{ColumnDefault, ColumnInfo}; - use tablepro_core::ddl::{ColumnAttributes, ColumnDefinition, DraftColumn, DraftType, diff_table}; + use tablepro_core::ddl::{ + ColumnAttributes, ColumnDefinition, DraftColumn, DraftType, PrimaryKeyConstraint, diff_table, + }; use tablepro_core::dialect::SqlDialect; use super::*; @@ -242,4 +245,237 @@ mod tests { .has_changes() ); } + + /// One engine's dialect with the integer and text types its catalogue + /// would report, and the name it gives a primary key. + struct Engine { + name: &'static str, + dialect: Box, + integer: &'static str, + text: &'static str, + key_name: Option<&'static str>, + } + + fn engines() -> Vec { + let server = |name, major| ServerInfo::new(name, format!("{major}.0"), major, 0, 0); + let engine = |name, dialect: Box, integer, text, key_name| Engine { + name, + dialect, + integer, + text, + key_name, + }; + vec![ + engine( + "PostgreSQL", + Box::new(drivers_postgres::PgDialect::new(&server("PostgreSQL", 18))), + "integer", + "text", + Some("t_pkey"), + ), + engine( + "MySQL", + Box::new(drivers_mysql::MySqlDialect::new( + drivers_mysql::MySqlFlavour::MySql, + &ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + )), + "int", + "varchar(40)", + Some("PRIMARY"), + ), + engine( + "MariaDB", + Box::new(drivers_mysql::MySqlDialect::new( + drivers_mysql::MySqlFlavour::MariaDb, + &ServerInfo::new("MariaDB", "11.4.5", 11, 4, 5), + )), + "int", + "varchar(40)", + Some("PRIMARY"), + ), + engine( + "SQL Server", + Box::new(drivers_mssql::MssqlDialect::new(16)), + "int", + "nvarchar(40)", + Some("PK_t"), + ), + engine( + "SQLite", + Box::new(drivers_sqlite::SqliteDialect::new(drivers_sqlite::SqliteVersion::new( + 3, 46, 0, + ))), + "INTEGER", + "TEXT", + None, + ), + engine( + "ClickHouse", + Box::new(drivers_clickhouse::ClickHouseDialect::new(&server("ClickHouse", 25))), + "Int32", + "String", + None, + ), + ] + } + + /// `id` keyed, `name`, `score` with a default and `note` with a + /// comment, as the engine's catalogue reads them. + fn table(engine: &Engine) -> TableDefinition { + let column = |name: &str, declaration: &str| { + let column_type = engine.dialect.parse_type(declaration).expect("the engine's own type"); + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type, + nullable: name != "id", + primary_key: name == "id", + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) + }; + let mut score = column("score", engine.integer); + score.info.default = ColumnDefault::Literal(tablepro_core::Value::Int(0)); + score.attributes.default_constraint_name = Some("DF_t_score".to_owned()); + let mut note = column("note", engine.text); + note.info.comment = Some("kept".to_owned()); + TableDefinition { + table: TableRef::new(Some("app".to_owned()), "t"), + columns: vec![column("id", engine.integer), column("name", engine.text), score, note], + primary_key: engine.key_name.map(|name| PrimaryKeyConstraint { + name: Some(name.to_owned()), + columns: vec!["id".to_owned()], + }), + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } + } + + /// One edit the Structure tab can make, aimed at a column by + /// position so every edit lands on some column. + #[derive(Debug, Clone)] + enum Edit { + RenameTable, + Rename(usize, usize), + Nullable(usize), + PrimaryKey(usize), + AutoIncrement(usize), + Default(usize, Option), + Retype(usize, bool), + Comment(usize), + Drop(usize), + Add(usize), + } + + const NAMES: [&str; 6] = ["id", "name", "score", "note", "fresh", "Score"]; + + fn edit() -> impl Strategy { + let column = 0usize..8; + let name = 0..NAMES.len(); + prop_oneof![ + Just(Edit::RenameTable), + (column.clone(), name.clone()).prop_map(|(column, name)| Edit::Rename(column, name)), + column.clone().prop_map(Edit::Nullable), + column.clone().prop_map(Edit::PrimaryKey), + column.clone().prop_map(Edit::AutoIncrement), + (column.clone(), proptest::option::of(-2i64..3)).prop_map(|(column, value)| Edit::Default(column, value)), + (column.clone(), any::()).prop_map(|(column, integer)| Edit::Retype(column, integer)), + column.clone().prop_map(Edit::Comment), + column.prop_map(Edit::Drop), + name.prop_map(Edit::Add), + ] + } + + fn column(draft: &mut TableDraft, index: usize) -> Option<&mut DraftColumn> { + let count = draft.columns.len(); + draft.columns.get_mut(index.checked_rem(count)?) + } + + fn apply(draft: &mut TableDraft, edit: &Edit, engine: &Engine) { + match *edit { + Edit::RenameTable => draft.table.name = "renamed".to_owned(), + Edit::Add(name) => draft + .columns + .push(DraftColumn::new(NAMES[name], DraftType::Typed(engine.text.to_owned()))), + Edit::Drop(index) => { + if let Some(index) = index.checked_rem(draft.columns.len()) { + draft.columns.remove(index); + } + } + Edit::Rename(index, name) => { + if let Some(column) = column(draft, index) { + column.name = NAMES[name].to_owned(); + } + } + Edit::Nullable(index) => { + if let Some(column) = column(draft, index) { + column.nullable = !column.nullable; + } + } + Edit::PrimaryKey(index) => { + if let Some(column) = column(draft, index) { + column.primary_key = !column.primary_key; + column.nullable &= !column.primary_key; + } + } + Edit::AutoIncrement(index) => { + if let Some(column) = column(draft, index) { + column.auto_increment = !column.auto_increment; + } + } + Edit::Default(index, value) => { + if let Some(column) = column(draft, index) { + column.default = value.map_or(ColumnDefault::None, |value| { + ColumnDefault::Literal(tablepro_core::Value::Int(value)) + }); + } + } + Edit::Retype(index, integer) => { + if let Some(column) = column(draft, index) { + let declaration = if integer { engine.integer } else { engine.text }; + column.column_type = DraftType::Typed(declaration.to_owned()); + } + } + Edit::Comment(index) => { + if let Some(column) = column(draft, index) { + column.comment = match column.comment { + Some(_) => None, + None => Some("new".to_owned()), + }; + } + } + } + } + + proptest! { + /// A save that changes something either writes a statement or is + /// refused: it never succeeds with the table left as it was. + #[test] + fn non_empty_changes_never_plan_empty(edits in proptest::collection::vec(edit(), 1..6)) { + for engine in engines() { + let original = table(&engine); + let mut draft = TableDraft::from_definition(&original); + for edit in &edits { + apply(&mut draft, edit, &engine); + } + let changes = diff_table(&original, &draft); + if changes.is_empty() { + continue; + } + let ddl = engine.dialect.ddl().expect("every engine plans its DDL"); + let plan = ddl.plan_alter_table(&original, &changes); + prop_assert!( + !matches!(&plan, Ok(plan) if plan.is_empty()), + "{} planned nothing for {changes:?}", + engine.name + ); + } + } + } } diff --git a/linux/crates/app/src/ui/error_text/ddl.rs b/linux/crates/app/src/ui/error_text/ddl.rs index a18cb0bf02..d6f8331260 100644 --- a/linux/crates/app/src/ui/error_text/ddl.rs +++ b/linux/crates/app/src/ui/error_text/ddl.rs @@ -16,6 +16,7 @@ pub fn refusal_text(reason: &RefusalReason) -> String { RefusalReason::IdentityColumn => { crate::i18n::gettext("The database numbers this column, so it has no default.") } + RefusalReason::NotAnInteger => crate::i18n::gettext("Only an integer column can number itself."), RefusalReason::UnmodeledAttribute(attribute) => crate::i18n::gettext_f( "This column has an attribute TablePro can't restate: {attribute}", &[("attribute", attribute)], diff --git a/linux/crates/core/src/ddl/ddl_plan.rs b/linux/crates/core/src/ddl/ddl_plan.rs index e4454bc3b3..0d85057f74 100644 --- a/linux/crates/core/src/ddl/ddl_plan.rs +++ b/linux/crates/core/src/ddl/ddl_plan.rs @@ -1,3 +1,4 @@ +use super::{BuildDdlError, DdlOperation, TableChanges}; use crate::foreign_key_mode::ForeignKeyMode; use crate::meta::TableRef; use crate::statement::{GuardedStatement, Statement, StatementBuilder}; @@ -28,6 +29,16 @@ impl DdlPlan { } } + /// The statements an engine wrote for a change to an existing table. + /// A change that came to no statement would save as done while the + /// table stayed as it was, so it is refused instead. + pub fn for_changes(changes: &TableChanges, statements: Vec) -> Result { + match statements.is_empty() && !changes.is_empty() { + true => Err(BuildDdlError::Unsupported(DdlOperation::AlterTable)), + false => Ok(Self::from_sql(statements)), + } + } + pub fn is_empty(&self) -> bool { self.statements.is_empty() } @@ -78,4 +89,22 @@ mod tests { assert!(batch.steps.iter().all(|step| step.statement.params().is_empty())); assert_eq!(batch.steps[1].statement.sql(), "DROP INDEX i"); } + + #[test] + fn a_change_that_wrote_nothing_is_refused() { + let renamed = TableChanges { + renamed_table: Some("u".to_owned()), + ..TableChanges::default() + }; + + assert_eq!( + DdlPlan::for_changes(&renamed, Vec::new()), + Err(BuildDdlError::Unsupported(DdlOperation::AlterTable)) + ); + assert!(DdlPlan::for_changes(&TableChanges::default(), Vec::new()).is_ok_and(|plan| plan.is_empty())); + assert_eq!( + DdlPlan::for_changes(&renamed, vec!["ALTER TABLE t RENAME TO u".to_owned()]).map(|plan| plan.len()), + Ok(1) + ); + } } diff --git a/linux/crates/core/src/ddl/mod.rs b/linux/crates/core/src/ddl/mod.rs index 0d5a7f7c15..324d67848d 100644 --- a/linux/crates/core/src/ddl/mod.rs +++ b/linux/crates/core/src/ddl/mod.rs @@ -26,6 +26,7 @@ mod drop_table_options; mod generated_column; mod identity_style; mod inbound_foreign_key; +mod order_renames; mod permission; mod primary_key_change; mod primary_key_constraint; @@ -64,6 +65,7 @@ pub use drop_table_options::DropTableOptions; pub use generated_column::{GeneratedColumn, GeneratedStorage}; pub use identity_style::IdentityStyle; pub use inbound_foreign_key::InboundForeignKey; +pub use order_renames::order_renames; pub use permission::Permission; pub use primary_key_change::PrimaryKeyChange; pub use primary_key_constraint::PrimaryKeyConstraint; diff --git a/linux/crates/core/src/ddl/order_renames.rs b/linux/crates/core/src/ddl/order_renames.rs new file mode 100644 index 0000000000..90accef6c1 --- /dev/null +++ b/linux/crates/core/src/ddl/order_renames.rs @@ -0,0 +1,233 @@ +use std::collections::{HashMap, HashSet}; + +use super::ColumnRename; + +const TEMPORARY_PREFIX: &str = "__tablepro_rename_"; + +/// The renames in an order that runs them one at a time, each into a +/// name that is free when it runs. A swap, or any longer cycle, has no +/// such order, so one column of it steps aside to a temporary +/// `__tablepro_rename_` name first and takes its new name last. +/// +/// `occupied` holds the columns the table has when the renames start, +/// the renamed ones included. `added` holds the columns added after +/// them, which a temporary name keeps clear of, as it does every name +/// the renames start from or end with. +/// +/// A rename into a name a column keeps until after the renames cannot +/// be ordered, and is left for the server to refuse. +/// +/// Names match without regard to case: MySQL, SQL Server and SQLite +/// compare column names that way, and the extra step this can cost a +/// case-sensitive engine changes nothing it ends with. +pub fn order_renames<'a>( + renames: &[ColumnRename], + occupied: impl IntoIterator, + added: impl IntoIterator, +) -> Vec { + let mut pending: Vec = renames + .iter() + .filter(|rename| rename.from != rename.to) + .cloned() + .collect(); + let mut held = Held::default(); + for name in occupied { + held.insert(name); + } + for rename in &pending { + if !held.contains(&rename.from) { + held.insert(&rename.from); + } + } + let mut reserved: HashSet = held.counts.keys().cloned().collect(); + reserved.extend(added.into_iter().map(fold)); + reserved.extend(pending.iter().map(|rename| fold(&rename.to))); + + let mut ordered = Vec::with_capacity(pending.len()); + let mut next_temporary = 1usize; + while !pending.is_empty() { + if let Some(index) = pending.iter().position(|rename| is_free(&held, rename)) { + let rename = pending.remove(index); + held.remove(&rename.from); + held.insert(&rename.to); + ordered.push(rename); + continue; + } + let Some(index) = cycle_member(&pending) else { + ordered.append(&mut pending); + break; + }; + let temporary = loop { + let name = format!("{TEMPORARY_PREFIX}{next_temporary}"); + next_temporary += 1; + if reserved.insert(fold(&name)) { + break name; + } + }; + let rename = &mut pending[index]; + held.remove(&rename.from); + held.insert(&temporary); + ordered.push(ColumnRename { + from: std::mem::replace(&mut rename.from, temporary.clone()), + to: temporary, + }); + } + ordered +} + +/// A rename into its own name in other case frees what it takes. +fn is_free(held: &Held, rename: &ColumnRename) -> bool { + same(&rename.from, &rename.to) || !held.contains(&rename.to) +} + +/// A rename whose new name another pending rename starts from, and so on +/// back to it. Each name starts at most one rename, so following the +/// names either comes back or runs out. +fn cycle_member(pending: &[ColumnRename]) -> Option { + let blocker = |index: usize| pending.iter().position(|other| same(&other.from, &pending[index].to)); + (0..pending.len()).find(|&start| { + let mut current = start; + for _ in 0..pending.len() { + match blocker(current) { + Some(next) if next == start => return true, + Some(next) => current = next, + None => return false, + } + } + false + }) +} + +fn fold(name: &str) -> String { + name.to_lowercase() +} + +fn same(left: &str, right: &str) -> bool { + fold(left) == fold(right) +} + +/// Column names in use, counted, since a case-sensitive engine can hold +/// two that fold to one. +#[derive(Default)] +struct Held { + counts: HashMap, +} + +impl Held { + fn insert(&mut self, name: &str) { + *self.counts.entry(fold(name)).or_default() += 1; + } + + fn remove(&mut self, name: &str) { + let key = fold(name); + if let Some(count) = self.counts.get_mut(&key) { + *count -= 1; + if *count == 0 { + self.counts.remove(&key); + } + } + } + + fn contains(&self, name: &str) -> bool { + self.counts.contains_key(&fold(name)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rename(from: &str, to: &str) -> ColumnRename { + ColumnRename { + from: from.to_owned(), + to: to.to_owned(), + } + } + + fn steps(ordered: &[ColumnRename]) -> Vec<(&str, &str)> { + ordered + .iter() + .map(|rename| (rename.from.as_str(), rename.to.as_str())) + .collect() + } + + #[test] + fn order_renames_simple() { + let ordered = order_renames( + &[rename("email", "mail"), rename("name", "full_name")], + ["id", "email", "name"], + [], + ); + + assert_eq!(steps(&ordered), [("email", "mail"), ("name", "full_name")]); + } + + #[test] + fn order_renames_runs_a_chain_from_its_free_end() { + let ordered = order_renames(&[rename("a", "b"), rename("b", "c")], ["a", "b"], []); + + assert_eq!(steps(&ordered), [("b", "c"), ("a", "b")]); + } + + #[test] + fn order_renames_swap_uses_temporary_name() { + let ordered = order_renames(&[rename("a", "b"), rename("b", "a")], ["id", "a", "b"], []); + + assert_eq!( + steps(&ordered), + [("a", "__tablepro_rename_1"), ("b", "a"), ("__tablepro_rename_1", "b")] + ); + } + + #[test] + fn order_renames_breaks_a_three_way_cycle_once() { + let ordered = order_renames( + &[rename("a", "b"), rename("b", "c"), rename("c", "a")], + ["a", "b", "c"], + [], + ); + + assert_eq!( + steps(&ordered), + [ + ("a", "__tablepro_rename_1"), + ("c", "a"), + ("b", "c"), + ("__tablepro_rename_1", "b"), + ] + ); + } + + #[test] + fn order_renames_avoids_added_column_name() { + let ordered = order_renames( + &[rename("a", "b"), rename("b", "a")], + ["a", "b", "__tablepro_rename_1"], + ["__TablePro_Rename_2"], + ); + + assert_eq!( + steps(&ordered), + [("a", "__tablepro_rename_3"), ("b", "a"), ("__tablepro_rename_3", "b")] + ); + } + + #[test] + fn order_renames_matches_names_without_case() { + let case_only = order_renames(&[rename("Email", "email")], ["id", "Email"], []); + let swapped = order_renames(&[rename("a", "B"), rename("b", "A")], ["a", "b"], []); + + assert_eq!(steps(&case_only), [("Email", "email")]); + assert_eq!( + steps(&swapped), + [("a", "__tablepro_rename_1"), ("b", "A"), ("__tablepro_rename_1", "B")] + ); + } + + #[test] + fn order_renames_leaves_a_name_still_held_to_the_server() { + let ordered = order_renames(&[rename("a", "b"), rename("c", "d")], ["a", "b", "c"], []); + + assert_eq!(steps(&ordered), [("c", "d"), ("a", "b")]); + } +} diff --git a/linux/crates/core/src/ddl/refusal_reason.rs b/linux/crates/core/src/ddl/refusal_reason.rs index d105cf99cd..008fb50fd8 100644 --- a/linux/crates/core/src/ddl/refusal_reason.rs +++ b/linux/crates/core/src/ddl/refusal_reason.rs @@ -16,6 +16,8 @@ pub enum RefusalReason { GeneratedColumn, /// The server numbers the column, which rules out a default. IdentityColumn, + /// Only an integer column can number itself. + NotAnInteger, /// The column carries an attribute this build does not model, which a /// restated definition would drop. UnmodeledAttribute(String), @@ -42,6 +44,7 @@ impl fmt::Display for RefusalReason { Self::KeyColumn => f.write_str("the column is part of the table's sorting or partition key"), Self::GeneratedColumn => f.write_str("the server computes the column"), Self::IdentityColumn => f.write_str("the server numbers the column"), + Self::NotAnInteger => f.write_str("only an integer column can number itself"), Self::UnmodeledAttribute(name) => write!(f, "the column has an attribute this app cannot restate: {name}"), Self::ColumnInUse(users) => write!(f, "still used by {}", users.join(", ")), Self::ReferencedByForeignKeys(keys) => write!(f, "referenced by foreign keys {}", keys.join(", ")), diff --git a/linux/crates/core/src/ddl/table_changes.rs b/linux/crates/core/src/ddl/table_changes.rs index 3cdcf46922..a876498a5f 100644 --- a/linux/crates/core/src/ddl/table_changes.rs +++ b/linux/crates/core/src/ddl/table_changes.rs @@ -1,6 +1,6 @@ use super::{ BuildDdlError, ColumnAlteration, ColumnDefinition, ColumnRename, DdlDialect, DdlOperation, DraftColumn, Permission, - PrimaryKeyChange, TableDefinition, primary_key_permission, + PrimaryKeyChange, RefusalReason, TableDefinition, order_renames, primary_key_permission, }; use crate::foreign_key_info::ForeignKeyInfo; use crate::meta::{IndexInfo, IndexKey, TableRef}; @@ -49,6 +49,35 @@ impl TableChanges { } } + /// The column renames as single statements run them, for an engine + /// that drops columns before it renames and adds columns after. + pub fn ordered_renames(&self, original: &TableDefinition) -> Vec { + let occupied = original + .columns + .iter() + .map(ColumnDefinition::name) + .filter(|name| !self.dropped_columns.iter().any(|column| column.name() == *name)); + let added = self.added_columns.iter().map(|column| column.name.as_str()); + order_renames(&self.renamed_columns, occupied, added) + } + + /// The name of the primary key this change drops, for an engine that + /// drops it by name. A key the catalogue gave no name cannot be + /// dropped that way, and adding the new one beside it would fail. + pub fn dropped_primary_key_name(&self) -> Result, BuildDdlError> { + let Some(key) = self.primary_key.as_ref().and_then(|change| change.from.as_ref()) else { + return Ok(None); + }; + match &key.name { + Some(name) => Ok(Some(name)), + None => Err(BuildDdlError::Refused { + column: key.columns.first().cloned().unwrap_or_default(), + operation: DdlOperation::AlterPrimaryKey, + reason: RefusalReason::UnreadableDefinition, + }), + } + } + /// Refuse a change the dialect says it cannot make, before any SQL /// is written. The structure tab gates its controls on the same /// answers, so this only fires for a caller that skipped them. diff --git a/linux/crates/core/src/ddl/tests.rs b/linux/crates/core/src/ddl/tests.rs index 59f0363769..53f4e4b3b1 100644 --- a/linux/crates/core/src/ddl/tests.rs +++ b/linux/crates/core/src/ddl/tests.rs @@ -207,7 +207,7 @@ fn an_unedited_draft_changes_nothing() { } #[test] -fn a_rename_alone_is_one_change_and_alters_nothing() { +fn diff_rename_only_is_one_change() { let original = users(); let mut draft = TableDraft::from_definition(&original); draft_column(&mut draft, "email").name = "mail".to_owned(); @@ -242,7 +242,7 @@ fn change_count_matches_user_visible_edits() { } #[test] -fn moving_the_primary_key_is_a_key_change_under_the_new_names() { +fn diff_primary_key_change_detected() { let original = users(); let mut draft = TableDraft::from_definition(&original); let id = draft_column(&mut draft, "id"); @@ -260,6 +260,68 @@ fn moving_the_primary_key_is_a_key_change_under_the_new_names() { assert_eq!(change.from.and_then(|key| key.name).as_deref(), Some("users_pkey")); } +#[test] +fn renames_into_a_dropped_name_and_a_swap_run_one_at_a_time() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + draft.columns.retain(|column| column.name != "legacy"); + draft.indexes.retain(|index| index.name != "by_legacy"); + draft_column(&mut draft, "email").name = "legacy".to_owned(); + draft_column(&mut draft, "account_id").name = "id".to_owned(); + draft_column(&mut draft, "id").name = "account_id".to_owned(); + draft.columns.push(DraftColumn::new( + "__tablepro_rename_1", + DraftType::Typed("text".to_owned()), + )); + + let changes = diff_table(&original, &draft); + let steps: Vec<(String, String)> = changes + .ordered_renames(&original) + .into_iter() + .map(|rename| (rename.from, rename.to)) + .collect(); + + let step = |from: &str, to: &str| (from.to_owned(), to.to_owned()); + assert_eq!( + steps, + [ + step("email", "legacy"), + step("id", "__tablepro_rename_2"), + step("account_id", "id"), + step("__tablepro_rename_2", "account_id"), + ] + ); +} + +#[test] +fn a_moved_key_is_dropped_by_its_name() { + let mut table = users(); + let mut draft = TableDraft::from_definition(&table); + draft_column(&mut draft, "account_id").primary_key = true; + draft_column(&mut draft, "account_id").nullable = false; + + assert_eq!( + diff_table(&table, &draft).dropped_primary_key_name(), + Ok(Some("users_pkey")) + ); + table.primary_key = Some(PrimaryKeyConstraint { + name: None, + columns: vec!["id".to_owned()], + }); + assert_eq!( + diff_table(&table, &draft).dropped_primary_key_name(), + Err(BuildDdlError::Refused { + column: "id".to_owned(), + operation: DdlOperation::AlterPrimaryKey, + reason: RefusalReason::UnreadableDefinition, + }) + ); + assert_eq!( + diff_table(&table, &TableDraft::from_definition(&table)).dropped_primary_key_name(), + Ok(None) + ); +} + #[test] fn table_draft_validate_reports_column_in_use() { let original = users(); diff --git a/linux/crates/drivers/clickhouse/src/ddl.rs b/linux/crates/drivers/clickhouse/src/ddl.rs index 4f305fb832..33a5104578 100644 --- a/linux/crates/drivers/clickhouse/src/ddl.rs +++ b/linux/crates/drivers/clickhouse/src/ddl.rs @@ -120,7 +120,7 @@ impl DdlDialect for ClickHouseDialect { for column in &changes.dropped_columns { statements.push(alter(format!("DROP COLUMN {}", self.quote_identifier(column.name())))); } - for rename in &changes.renamed_columns { + for rename in changes.ordered_renames(original) { statements.push(alter(format!( "RENAME COLUMN {} TO {}", self.quote_identifier(&rename.from), @@ -157,7 +157,7 @@ impl DdlDialect for ClickHouseDialect { for column in &changes.added_columns { statements.push(alter(format!("ADD COLUMN {}", self.column_definition(column)?))); } - Ok(DdlPlan::from_sql(statements)) + DdlPlan::for_changes(changes, statements) } /// SHOW CREATE TABLE, which carries the engine, the keys and the @@ -422,6 +422,23 @@ mod tests { ); } + #[test] + fn a_swap_steps_aside_through_a_temporary_name() { + let plan = alter(&events(), |draft| { + draft.columns[1].name = "note".to_owned(); + draft.columns[2].name = "label".to_owned(); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE `analytics`.`events` RENAME COLUMN `label` TO `__tablepro_rename_1`", + "ALTER TABLE `analytics`.`events` RENAME COLUMN `note` TO `label`", + "ALTER TABLE `analytics`.`events` RENAME COLUMN `__tablepro_rename_1` TO `note`", + ] + ); + } + #[test] fn the_script_is_the_servers_own_text() { let mut table = events(); diff --git a/linux/crates/drivers/clickhouse/tests/integration/ddl.rs b/linux/crates/drivers/clickhouse/tests/integration/ddl.rs new file mode 100644 index 0000000000..5ee4716e34 --- /dev/null +++ b/linux/crates/drivers/clickhouse/tests/integration/ddl.rs @@ -0,0 +1,70 @@ +//! Structure changes the ClickHouse dialect plans, run against a real +//! server. + +use tablepro_core::Connection; +use tablepro_core::ddl::{TableDefinition, TableDraft, diff_table}; + +use crate::support::{CALL, connect, scalar, setup, start, table, text, within}; + +async fn read(connection: &dyn Connection) -> TableDefinition { + connection + .fetch_table_definition(&table("kept"), within(CALL)) + .await + .expect("the definition") +} + +/// ClickHouse runs each rename on its own and refuses one into a name +/// still in use, so a swap goes through a temporary name. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn swap_column_names_applies() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE tablepro.kept (id UInt64, a String, b Int32) ENGINE = MergeTree ORDER BY id", + "INSERT INTO tablepro.kept VALUES (1, 'x', 2)", + ], + ) + .await; + let before = read(connection.as_ref()).await; + let mut draft = TableDraft::from_definition(&before); + for column in &mut draft.columns { + column.name = match column.name.as_str() { + "a" => "b", + "b" => "a", + other => other, + } + .to_owned(); + } + let dialect = connection.dialect(); + let plan = dialect + .ddl() + .expect("ClickHouse plans its DDL") + .plan_alter_table(&before, &diff_table(&before, &draft)) + .expect("the change plans"); + + connection + .write_batch(&plan.into_write_batch(table("kept")), within(CALL)) + .await + .expect("the change runs"); + let after = read(connection.as_ref()).await; + + let names: Vec<&str> = after.columns.iter().map(|column| column.name()).collect(); + assert_eq!(names, ["id", "b", "a"]); + let type_of = |name: &str| after.column(name).map(|column| column.info.column_type.name.clone()); + assert_eq!(type_of("b").as_deref(), Some("String")); + assert_eq!(type_of("a").as_deref(), Some("Int32")); + assert_eq!( + text( + &scalar( + connection.as_ref(), + "SELECT concat(b, '|', toString(a)) FROM tablepro.kept" + ) + .await + ), + "x|2" + ); + connection.close().await.expect("a close"); +} diff --git a/linux/crates/drivers/clickhouse/tests/integration/main.rs b/linux/crates/drivers/clickhouse/tests/integration/main.rs index 2688ed0392..c4200f848e 100644 --- a/linux/crates/drivers/clickhouse/tests/integration/main.rs +++ b/linux/crates/drivers/clickhouse/tests/integration/main.rs @@ -7,6 +7,7 @@ )] mod conformance_engine; +mod ddl; mod keys_and_decimals; mod metadata; mod sessions; diff --git a/linux/crates/drivers/mssql/src/ddl.rs b/linux/crates/drivers/mssql/src/ddl.rs index 752562cdea..31716533ad 100644 --- a/linux/crates/drivers/mssql/src/ddl.rs +++ b/linux/crates/drivers/mssql/src/ddl.rs @@ -167,15 +167,15 @@ impl DdlDialect for MssqlDialect { self.quote_identifier(&index.name) )); } - let old_key = changes.primary_key.as_ref().and_then(|change| change.from.as_ref()); - if let Some(name) = old_key.and_then(|key| key.name.as_ref()) { + let old_key = changes.dropped_primary_key_name()?; + if let Some(name) = old_key { statements.push(alter(format!("DROP CONSTRAINT {}", self.quote_identifier(name)))); } for column in &changes.dropped_columns { statements.extend(self.drop_default(&table, column)); statements.push(alter(format!("DROP COLUMN {}", self.quote_identifier(column.name())))); } - for rename in &changes.renamed_columns { + for rename in changes.ordered_renames(original) { let object = format!("{}.{}", qualified, self.quote_identifier(&rename.from)); statements.push(format!( "EXEC sp_rename {}, {}, N'COLUMN'", @@ -248,7 +248,6 @@ impl DdlDialect for MssqlDialect { } if let Some(change) = changes.primary_key.as_ref().filter(|change| !change.to.is_empty()) { let constraint = old_key - .and_then(|key| key.name.as_ref()) .map(|name| format!("CONSTRAINT {} ", self.quote_identifier(name))) .unwrap_or_default(); statements.push(alter(format!( @@ -265,7 +264,7 @@ impl DdlDialect for MssqlDialect { for key in &changes.added_foreign_keys { statements.push(alter(format!("ADD {}", foreign_key_sql(self, &table, key)))); } - Ok(DdlPlan::from_sql(statements)) + DdlPlan::for_changes(changes, statements) } } @@ -760,6 +759,23 @@ mod tests { assert_eq!(sql(added), ["ALTER TABLE [dbo].[orders] ADD DEFAULT (N'x') FOR [note]"]); } + #[test] + fn a_swap_steps_aside_through_a_temporary_name() { + let plan = alter(&orders(), |draft| { + draft.columns[1].name = "note".to_owned(); + draft.columns[2].name = "status".to_owned(); + }); + + assert_eq!( + sql(plan), + [ + "EXEC sp_rename N'[dbo].[orders].[status]', N'__tablepro_rename_1', N'COLUMN'", + "EXEC sp_rename N'[dbo].[orders].[note]', N'status', N'COLUMN'", + "EXEC sp_rename N'[dbo].[orders].[__tablepro_rename_1]', N'note', N'COLUMN'", + ] + ); + } + #[test] fn a_dropped_column_loses_its_default_constraint_first() { let plan = alter(&orders(), |draft| { @@ -776,7 +792,7 @@ mod tests { } #[test] - fn sp_rename_quotes_parts_with_national_literals() { + fn sp_rename_quotes_parts() { let plan = alter(&orders(), |draft| { draft.table.name = "đơn hàng".to_owned(); draft.columns[2].name = "o'brien".to_owned(); @@ -816,7 +832,7 @@ mod tests { } #[test] - fn a_primary_key_change_drops_and_adds_the_named_constraint() { + fn pk_change_order_and_name_reuse() { let plan = alter(&orders(), |draft| { draft.columns[2].primary_key = true; draft.columns[2].nullable = false; diff --git a/linux/crates/drivers/mssql/tests/integration/ddl.rs b/linux/crates/drivers/mssql/tests/integration/ddl.rs index 8028e1df1c..d22d408800 100644 --- a/linux/crates/drivers/mssql/tests/integration/ddl.rs +++ b/linux/crates/drivers/mssql/tests/integration/ddl.rs @@ -1,10 +1,11 @@ //! Structure changes the SQL Server dialect plans, run against a real //! server. -use tablepro_core::Connection; -use tablepro_core::ddl::{DraftType, TableDefinition, TableDraft, diff_table}; +use tablepro_core::ddl::{DdlPlan, DraftColumn, DraftType, TableDefinition, TableDraft, diff_table}; +use tablepro_core::value::Value; +use tablepro_core::{Connection, DriverError, WriteKind}; -use crate::support::{CALL, connect, dbo, setup, start, within}; +use crate::support::{CALL, apply, connect, dbo, scalar, setup, start, within}; async fn read(connection: &dyn Connection) -> TableDefinition { connection @@ -13,8 +14,51 @@ async fn read(connection: &dyn Connection) -> TableDefinition { .expect("the definition") } -/// ALTER COLUMN gives the column the database's collation and drops -/// SPARSE and a data mask unless the plan restates them. +/// The plan the structure tab would save for the edit, against `before`. +fn plan(connection: &dyn Connection, before: &TableDefinition, edit: impl FnOnce(&mut TableDraft)) -> DdlPlan { + let mut draft = TableDraft::from_definition(before); + edit(&mut draft); + connection + .dialect() + .ddl() + .expect("SQL Server plans its DDL") + .plan_alter_table(before, &diff_table(before, &draft)) + .expect("the change plans") +} + +async fn run(connection: &dyn Connection, plan: DdlPlan) -> Result<(), DriverError> { + connection + .write_batch(&plan.into_write_batch(dbo("kept")), within(CALL)) + .await + .map(|_| ()) +} + +/// Plan the edit against the table as read, run it, and read it again. +async fn change(connection: &dyn Connection, edit: impl Fn(&mut DraftColumn)) -> TableDefinition { + let before = read(connection).await; + let plan = plan(connection, &before, |draft| draft.columns.iter_mut().for_each(edit)); + run(connection, plan).await.expect("the change runs"); + read(connection).await +} + +fn names(definition: &TableDefinition) -> Vec<&str> { + definition.columns.iter().map(|column| column.name()).collect() +} + +fn type_of(definition: &TableDefinition, column: &str) -> String { + definition + .column(column) + .map(|column| column.info.column_type.name.clone()) + .unwrap_or_default() +} + +async fn text(connection: &dyn Connection, sql: &str) -> String { + match scalar(connection, sql).await { + Value::Text(text) => text, + other => tablepro_core::export::value_to_text(&other).unwrap_or_default(), + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn nullability_change_keeps_column_collation() { @@ -83,3 +127,150 @@ async fn nullability_change_keeps_column_collation() { assert_eq!(column("email").info.column_type.name, "varchar(80)"); connection.close().await.expect("a close"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn rename_and_retype_column_applies() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE dbo.kept (id int PRIMARY KEY, code varchar(10) NULL, qty smallint NULL)", + "INSERT INTO dbo.kept VALUES (1, 'a', 5)", + ], + ) + .await; + + let after = change(connection.as_ref(), |column| match column.name.as_str() { + "code" => { + column.name = "label".to_owned(); + column.column_type = DraftType::Typed("nvarchar(40)".to_owned()); + } + "qty" => { + column.name = "amount".to_owned(); + column.column_type = DraftType::Typed("bigint".to_owned()); + } + _ => {} + }) + .await; + + assert_eq!(names(&after), ["id", "label", "amount"]); + assert_eq!(type_of(&after, "label"), "nvarchar(40)"); + assert_eq!(type_of(&after, "amount"), "bigint"); + assert_eq!( + text( + connection.as_ref(), + "SELECT label + N'|' + CAST(amount AS nvarchar(20)) FROM dbo.kept" + ) + .await, + "a|5" + ); + connection.close().await.expect("a close"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn swap_column_names_applies() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE dbo.kept (id int PRIMARY KEY, a nvarchar(10) NULL, b int NULL)", + "INSERT INTO dbo.kept VALUES (1, N'x', 2)", + ], + ) + .await; + + let after = change(connection.as_ref(), |column| { + column.name = match column.name.as_str() { + "a" => "B", + "b" => "A", + other => other, + } + .to_owned(); + }) + .await; + + assert_eq!(names(&after), ["id", "B", "A"]); + assert_eq!(type_of(&after, "B"), "nvarchar(10)"); + assert_eq!(type_of(&after, "A"), "int"); + assert_eq!( + text( + connection.as_ref(), + "SELECT B + N'|' + CAST(A AS nvarchar(20)) FROM dbo.kept" + ) + .await, + "x|2" + ); + connection.close().await.expect("a close"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn change_primary_key_to_composite_applies() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE dbo.kept (order_id int NOT NULL CONSTRAINT PK_kept PRIMARY KEY, line int NULL, sku \ + nvarchar(10) COLLATE Latin1_General_BIN NULL)", + "INSERT INTO dbo.kept VALUES (1, 1, N'a'), (2, 1, N'b')", + ], + ) + .await; + + let after = change(connection.as_ref(), |column| { + if column.name == "line" { + column.primary_key = true; + column.nullable = false; + } + }) + .await; + + let key = after.primary_key.clone().expect("a primary key"); + assert_eq!(key.name.as_deref(), Some("PK_kept")); + assert_eq!(key.columns, ["order_id", "line"]); + assert!(!after.column("line").expect("line").info.nullable); + apply( + connection.as_ref(), + WriteKind::RowChanges, + &["INSERT INTO dbo.kept VALUES (1, 2, N'c')"], + ) + .await + .expect("a second line of order 1"); + let repeated = apply( + connection.as_ref(), + WriteKind::RowChanges, + &["INSERT INTO dbo.kept VALUES (1, 1, N'd')"], + ) + .await; + assert!(repeated.is_err(), "the key does not hold the line"); + connection.close().await.expect("a close"); +} + +/// sp_rename splits its first argument on periods outside brackets, so +/// each part goes in brackets with its `]` doubled. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn rename_column_with_period_and_bracket_applies() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &["CREATE TABLE dbo.kept (id int PRIMARY KEY, [a.b]]c] int NULL)"], + ) + .await; + + let renamed = change(connection.as_ref(), |column| { + if column.name == "a.b]c" { + column.name = "d.e]f".to_owned(); + } + }) + .await; + + assert_eq!(names(&renamed), ["id", "d.e]f"]); + connection.close().await.expect("a close"); +} diff --git a/linux/crates/drivers/mysql/src/ddl.rs b/linux/crates/drivers/mysql/src/ddl.rs index 188478ce45..3e93961b60 100644 --- a/linux/crates/drivers/mysql/src/ddl.rs +++ b/linux/crates/drivers/mysql/src/ddl.rs @@ -71,10 +71,13 @@ const TYPE_SUGGESTIONS: &[&str] = &[ /// MySQL restates a column whole, so every changed column is one /// MODIFY or CHANGE carrying its full definition, and the column changes -/// share one ALTER TABLE. A foreign key is dropped in a statement before -/// it and added in one after it: MySQL refuses to drop and add a key of -/// the same name in one statement. MySQL commits each DDL statement on -/// its own, so a failure leaves the statements before it applied. +/// share one ALTER TABLE. Each clause of it names columns as the table +/// had them, so renames that swap names, or take the name of a column +/// the same statement drops, need no order. A foreign key is dropped in +/// a statement before it and added in one after it: MySQL refuses to +/// drop and add a key of the same name in one statement. MySQL commits +/// each DDL statement on its own, so a failure leaves the statements +/// before it applied. impl DdlDialect for MySqlDialect { fn capabilities(&self) -> &DdlCapabilities { match self.has_functional_key_parts() { @@ -248,7 +251,7 @@ impl DdlDialect for MySqlDialect { .collect(); statements.push(format!("ALTER TABLE {qualified} {}", adds.join(", "))); } - Ok(DdlPlan::from_sql(statements)) + DdlPlan::for_changes(changes, statements) } /// The server's own SHOW CREATE TABLE, which carries the engine, @@ -530,7 +533,7 @@ mod tests { } #[test] - fn a_primary_key_change_is_one_statement() { + fn pk_change_single_statement() { let plan = alter(&users(), |draft| { draft.columns[1].primary_key = true; draft.columns[1].nullable = false; @@ -545,6 +548,38 @@ mod tests { ); } + #[test] + fn a_swap_is_one_statement_against_the_original_names() { + let plan = alter(&users(), |draft| { + draft.columns[1].name = "account_id".to_owned(); + draft.columns[2].name = "email".to_owned(); + draft.columns[2].nullable = false; + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE `app`.`users` RENAME COLUMN `email` TO `account_id`, CHANGE COLUMN `account_id` `email` \ + int NOT NULL" + ] + ); + } + + #[test] + fn auto_increment_goes_through_the_definition() { + let mut table = users(); + table.columns[0].info.is_auto_increment = true; + + let off = alter(&table, |draft| draft.columns[0].auto_increment = false); + let on = alter(&users(), |draft| draft.columns[0].auto_increment = true); + + assert_eq!(sql(off), ["ALTER TABLE `app`.`users` MODIFY COLUMN `id` int NOT NULL"]); + assert_eq!( + sql(on), + ["ALTER TABLE `app`.`users` MODIFY COLUMN `id` int NOT NULL AUTO_INCREMENT"] + ); + } + fn referenced(table: &mut TableDefinition, column: &str) { table.inbound_foreign_keys = vec![InboundForeignKey { name: "orders_user_fk".to_owned(), diff --git a/linux/crates/drivers/mysql/tests/integration/ddl.rs b/linux/crates/drivers/mysql/tests/integration/ddl.rs index af94b7fa05..5f06c01022 100644 --- a/linux/crates/drivers/mysql/tests/integration/ddl.rs +++ b/linux/crates/drivers/mysql/tests/integration/ddl.rs @@ -2,13 +2,13 @@ use tablepro_core::column::ColumnDefault; use tablepro_core::ddl::{ - BuildDdlError, CollationName, ColumnVisibility, DraftColumn, GeneratedStorage, Permission, RefusalReason, - TableDefinition, TableDraft, diff_table, + BuildDdlError, CollationName, ColumnVisibility, DraftColumn, DraftType, GeneratedStorage, Permission, + RefusalReason, TableDefinition, TableDraft, diff_table, }; use tablepro_core::{Connection, WriteKind}; use tablepro_test_fixtures::{MysqlFlavour, MysqlOptions}; -use crate::support::{CALL, apply, connect, scalar, setup, start, start_with, table, text, within}; +use crate::support::{CALL, FLAVOURS, apply, connect, scalar, setup, start, start_with, table, text, within}; async fn read(connection: &dyn Connection) -> TableDefinition { connection @@ -330,3 +330,150 @@ async fn mariadb_check_follows_its_column_through_a_rename() { ); connection.close().await.expect("a close"); } + +fn type_of(definition: &TableDefinition, column: &str) -> String { + definition + .column(column) + .map(|column| column.info.column_type.name.clone()) + .unwrap_or_default() +} + +fn names(definition: &TableDefinition) -> Vec<&str> { + definition.columns.iter().map(|column| column.name()).collect() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn rename_and_retype_column_applies() { + let fixture = start(MysqlFlavour::Mysql).await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE kept (id int PRIMARY KEY, code varchar(10) NULL COMMENT 'sku', qty smallint NULL)", + "INSERT INTO kept VALUES (1, 'a', 5)", + ], + ) + .await; + + let (_, after) = change(connection.as_ref(), |column| match column.name.as_str() { + "code" => { + column.name = "label".to_owned(); + column.column_type = DraftType::Typed("varchar(40)".to_owned()); + } + "qty" => column.name = "amount".to_owned(), + _ => {} + }) + .await; + + assert_eq!(names(&after), ["id", "label", "amount"]); + assert_eq!(type_of(&after, "label"), "varchar(40)"); + assert_eq!(type_of(&after, "amount"), "smallint"); + assert_eq!( + after + .column("label") + .and_then(|column| column.info.comment.clone()) + .as_deref(), + Some("sku") + ); + assert_eq!( + text(&scalar(connection.as_ref(), "SELECT concat(label, '|', amount) FROM kept").await), + "a|5" + ); + connection.close().await.expect("a close"); +} + +/// Every clause of one ALTER names columns as the table had them, so a +/// swap and a cycle of renames need no temporary name, on either server. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn swap_column_names_applies() { + for flavour in FLAVOURS { + let fixture = start(flavour).await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE kept (id int PRIMARY KEY, a varchar(10) NULL, b int NULL, c date NULL, KEY by_a (a))", + "INSERT INTO kept VALUES (1, 'x', 2, '2026-10-14')", + ], + ) + .await; + + let (_, after) = change(connection.as_ref(), |column| { + let renamed = match column.name.as_str() { + "a" => "b", + "b" => "c", + "c" => "a", + _ => return, + }; + column.name = renamed.to_owned(); + if renamed == "c" { + column.nullable = false; + } + }) + .await; + + assert_eq!(names(&after), ["id", "b", "c", "a"], "{flavour:?}"); + assert!(type_of(&after, "b").starts_with("varchar"), "{flavour:?}"); + assert!(type_of(&after, "c").starts_with("int"), "{flavour:?}"); + assert_eq!(type_of(&after, "a"), "date", "{flavour:?}"); + assert!(!after.column("c").expect("c").info.nullable, "{flavour:?}"); + assert_eq!( + text(&scalar(connection.as_ref(), "SELECT concat(b, '|', c, '|', a) FROM kept").await), + "x|2|2026-10-14", + "{flavour:?}" + ); + let indexed = scalar( + connection.as_ref(), + "SELECT column_name FROM information_schema.statistics WHERE table_schema = DATABASE() AND \ + table_name = 'kept' AND index_name = 'by_a'", + ) + .await; + assert_eq!(text(&indexed), "b", "{flavour:?}"); + connection.close().await.expect("a close"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn change_primary_key_to_composite_applies() { + let fixture = start(MysqlFlavour::Mysql).await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE kept (order_id int PRIMARY KEY, line int NULL, sku varchar(10) NULL)", + "INSERT INTO kept VALUES (1, 1, 'a'), (2, 1, 'b')", + ], + ) + .await; + + let (_, after) = change(connection.as_ref(), |column| { + if column.name == "line" { + column.primary_key = true; + column.nullable = false; + } + }) + .await; + + assert_eq!( + after.primary_key.as_ref().map(|key| key.columns.clone()), + Some(vec!["order_id".to_owned(), "line".to_owned()]) + ); + apply( + connection.as_ref(), + WriteKind::RowChanges, + &["INSERT INTO kept VALUES (1, 2, 'c')"], + ) + .await + .expect("a second line of order 1"); + let repeated = apply( + connection.as_ref(), + WriteKind::RowChanges, + &["INSERT INTO kept VALUES (1, 1, 'd')"], + ) + .await; + assert!(repeated.is_err(), "the key does not hold the line"); + connection.close().await.expect("a close"); +} diff --git a/linux/crates/drivers/postgres/src/ddl.rs b/linux/crates/drivers/postgres/src/ddl.rs index fc7d514ecc..fea8f556c2 100644 --- a/linux/crates/drivers/postgres/src/ddl.rs +++ b/linux/crates/drivers/postgres/src/ddl.rs @@ -1,8 +1,9 @@ use tablepro_core::column::{ColumnKind, ColumnType, IntegerKind, IntegerWidth}; use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, default_sql, foreign_key_sql}; use tablepro_core::ddl::{ - BuildDdlError, CollationName, DdlCapabilities, DdlDialect, DdlPlan, DraftColumn, GeneratedStorage, IdentityStyle, - TableChanges, TableDefinition, TableDraft, + BuildDdlError, CollationName, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlOperation, + DdlPlan, DraftColumn, GeneratedStorage, IdentityStyle, Permission, RefusalReason, TableChanges, TableDefinition, + TableDraft, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; @@ -20,9 +21,7 @@ static CAPABILITIES: DdlCapabilities = DdlCapabilities { alter_column_nullability: true, alter_column_default: true, alter_primary_key: true, - // An identity added to a column with rows has to start past them, - // which needs a setval this plan does not write yet. - alter_auto_increment: false, + alter_auto_increment: true, create_index: true, drop_index: true, expression_index_keys: true, @@ -55,9 +54,10 @@ const TYPE_SUGGESTIONS: &[&str] = &[ /// PostgreSQL changes a table in one ALTER TABLE: it orders the /// subcommands itself, drops before adds, so one statement carries -/// every column change and the whole change commits or none of it does. -/// Renames cannot share it, and indexes and comments are statements of -/// their own. +/// every column change. Renames cannot share it, and indexes, comments +/// and an identity's next value are statements of their own. The write +/// runs them in one transaction, so the whole change commits or none of +/// it does. impl DdlDialect for PgDialect { fn capabilities(&self) -> &DdlCapabilities { &CAPABILITIES @@ -67,6 +67,14 @@ impl DdlDialect for PgDialect { self } + /// Identity is added and dropped in place, so turning it on or off + /// restates nothing else about the column. + fn column_capabilities(&self, table: &TableDefinition, column: &ColumnDefinition) -> ColumnCapabilities { + let mut permissions = ColumnCapabilities::standard(&CAPABILITIES, table, column); + permissions.alter_auto_increment = identity_permission(column); + permissions + } + fn type_suggestions(&self) -> &'static [&'static str] { TYPE_SUGGESTIONS } @@ -120,13 +128,6 @@ impl DdlDialect for PgDialect { } let table = changes.target(&original.table); let qualified = self.qualify(&table); - for rename in &changes.renamed_columns { - statements.push(format!( - "ALTER TABLE {qualified} RENAME COLUMN {} TO {}", - self.quote_identifier(&rename.from), - self.quote_identifier(&rename.to) - )); - } // A column drop takes its indexes with it, so the explicit drops // go first. for index in &changes.dropped_indexes { @@ -134,25 +135,41 @@ impl DdlDialect for PgDialect { statements.push(format!("DROP INDEX {}", self.qualify(&index))); } - let mut actions = Vec::new(); + let mut drops = Vec::new(); for key in &changes.dropped_foreign_keys { - actions.push(format!("DROP CONSTRAINT {}", self.quote_identifier(&key.name))); + drops.push(format!("DROP CONSTRAINT {}", self.quote_identifier(&key.name))); } - let old_key = changes - .primary_key - .as_ref() - .and_then(|change| change.from.as_ref()) - .and_then(|key| key.name.as_ref()); - if let Some(name) = old_key { - actions.push(format!("DROP CONSTRAINT {}", self.quote_identifier(name))); + if let Some(name) = changes.dropped_primary_key_name()? { + drops.push(format!("DROP CONSTRAINT {}", self.quote_identifier(name))); } for column in &changes.dropped_columns { - actions.push(format!("DROP COLUMN {}", self.quote_identifier(column.name()))); + drops.push(format!("DROP COLUMN {}", self.quote_identifier(column.name()))); } + // RENAME COLUMN takes a statement of its own. The drops run before + // it, so a column can take the name of one the change drops. + let renames = changes.ordered_renames(original); + let mut actions = match renames.is_empty() { + true => drops, + false => { + if !drops.is_empty() { + statements.push(format!("ALTER TABLE {qualified} {}", drops.join(", "))); + } + for rename in &renames { + statements.push(format!( + "ALTER TABLE {qualified} RENAME COLUMN {} TO {}", + self.quote_identifier(&rename.from), + self.quote_identifier(&rename.to) + )); + } + Vec::new() + } + }; + let mut sequences = Vec::new(); for alteration in &changes.altered_columns { let draft = &alteration.draft; let name = self.quote_identifier(&draft.name); let changed = alteration.changed; + let numbering = changed.auto_increment && draft.auto_increment; if changed.column_type { let resolved = draft.column_type.resolve(self, &draft.name)?; // A new type takes its own default collation unless the @@ -170,7 +187,9 @@ impl DdlDialect for PgDialect { resolved.sql, resolved.sql )); } - if changed.nullability { + // An identity column is NOT NULL whatever the switch said, and + // turning one on sets that below. + if changed.nullability && !numbering { let action = if draft.nullable { "DROP NOT NULL" } else { @@ -185,6 +204,28 @@ impl DdlDialect for PgDialect { None => format!("ALTER COLUMN {name} DROP DEFAULT"), }); } + if numbering { + self.check_identity(draft)?; + actions.push(format!("ALTER COLUMN {name} SET NOT NULL")); + actions.push(format!("ALTER COLUMN {name} ADD GENERATED BY DEFAULT AS IDENTITY")); + sequences.push(self.identity_restart(&table, &draft.name)?); + } else if changed.auto_increment { + actions.push(match alteration.original.attributes.identity { + IdentityStyle::GeneratedAlways | IdentityStyle::GeneratedByDefault => { + format!("ALTER COLUMN {name} DROP IDENTITY") + } + // A serial column numbers itself through its default; + // the sequence stays, owned by the column. + IdentityStyle::SequenceDefault => format!("ALTER COLUMN {name} DROP DEFAULT"), + _ => { + return Err(BuildDdlError::Refused { + column: alteration.original.name().to_owned(), + operation: DdlOperation::AlterAutoIncrement, + reason: RefusalReason::UnreadableDefinition, + }); + } + }); + } } for column in &changes.added_columns { actions.push(format!("ADD COLUMN {}", self.column_definition(column)?)); @@ -198,6 +239,7 @@ impl DdlDialect for PgDialect { if !actions.is_empty() { statements.push(format!("ALTER TABLE {qualified} {}", actions.join(", "))); } + statements.extend(sequences); let altered_comments = changes .altered_columns @@ -217,11 +259,51 @@ impl DdlDialect for PgDialect { .iter() .map(|index| create_index_sql(self, &table, index)), ); - Ok(DdlPlan::from_sql(statements)) + DdlPlan::for_changes(changes, statements) } } impl PgDialect { + /// The column as the save leaves it has to take an identity: an + /// integer, with no default of its own. + fn check_identity(&self, draft: &DraftColumn) -> Result<(), BuildDdlError> { + let refused = |reason| BuildDdlError::Refused { + column: draft.name.clone(), + operation: DdlOperation::AlterAutoIncrement, + reason, + }; + let column_type = draft.column_type.column_type(self, &draft.name)?; + if !matches!(column_type.kind, ColumnKind::Integer(_)) { + return Err(refused(RefusalReason::NotAnInteger)); + } + if !draft.default.is_none() { + return Err(refused(RefusalReason::ConflictingAttribute("DEFAULT".to_owned()))); + } + Ok(()) + } + + /// A new identity starts at 1, so it is moved past the largest key + /// already there. `pg_get_serial_sequence` parses the table as SQL, + /// so it goes in quoted, and takes the column name as it is. + fn identity_restart(&self, table: &TableRef, column: &str) -> Result { + let text = |text: String| { + SqlDialect::literal(self, &Value::Text(text), &ColumnType::untyped_text("text")).map_err(|error| { + BuildDdlError::Literal { + column: column.to_owned(), + error, + } + }) + }; + let qualified = self.qualify(table); + Ok(format!( + "SELECT setval(pg_get_serial_sequence({}, {}), GREATEST(COALESCE(MAX({})::bigint, 0), 0) + 1, false) \ + FROM {qualified}", + text(qualified.clone())?, + text(column.to_owned())?, + self.quote_identifier(column) + )) + } + /// A column as CREATE TABLE and ADD COLUMN declare it. A column read /// from the catalogue keeps its collation, its identity style and its /// generation expression. @@ -283,6 +365,28 @@ impl PgDialect { } } +/// Turning numbering off drops the identity or the serial default. +/// Turning it on takes an integer column the server does not compute +/// and that has no default the identity would fight. +fn identity_permission(column: &ColumnDefinition) -> Permission { + match column.attributes.identity { + IdentityStyle::GeneratedAlways | IdentityStyle::GeneratedByDefault | IdentityStyle::SequenceDefault => { + Permission::Allowed + } + _ if column.info.is_auto_increment => Permission::Refused(RefusalReason::UnreadableDefinition), + _ if column.info.is_generated || column.attributes.generated.is_some() => { + Permission::Refused(RefusalReason::GeneratedColumn) + } + _ if !matches!(column.info.column_type.kind, ColumnKind::Integer(_)) => { + Permission::Refused(RefusalReason::NotAnInteger) + } + _ if !column.info.default.is_none() => { + Permission::Refused(RefusalReason::ConflictingAttribute("DEFAULT".to_owned())) + } + _ => Permission::Allowed, + } +} + /// Whether a type takes a COLLATE clause: text, and arrays of it. fn collatable(kind: &ColumnKind) -> bool { match kind { @@ -316,8 +420,8 @@ mod tests { use tablepro_core::ServerInfo; use tablepro_core::column::{ColumnDefault, ColumnInfo, SqlExpression}; use tablepro_core::ddl::{ - ColumnAttributes, ColumnDefinition, DdlOperation, DraftType, DropTableOptions, InboundForeignKey, Permission, - PrimaryKeyConstraint, ReferentialAction, RefusalReason, diff_table, + ColumnAttributes, DraftType, DropTableOptions, InboundForeignKey, PrimaryKeyConstraint, ReferentialAction, + diff_table, }; use tablepro_core::meta::{IndexInfo, IndexKey}; @@ -517,7 +621,7 @@ mod tests { } #[test] - fn renames_run_first_and_the_rest_names_the_new_table() { + fn rename_then_alter_uses_new_name() { let plan = alter(&users(), |draft| { draft.table.name = "members".to_owned(); draft.columns[1].name = "mail".to_owned(); @@ -534,6 +638,36 @@ mod tests { ); } + #[test] + fn a_swap_steps_aside_and_a_dropped_name_is_free_for_a_rename() { + let swapped = alter(&users(), |draft| { + draft.columns[1].name = "status".to_owned(); + draft.columns[2].name = "email".to_owned(); + }); + let replaced = alter(&users(), |draft| { + draft.columns.remove(2); + draft.columns[1].name = "status".to_owned(); + draft.columns.push(typed("email", "text")); + }); + + assert_eq!( + sql(swapped), + [ + "ALTER TABLE \"app\".\"users\" RENAME COLUMN \"email\" TO \"__tablepro_rename_1\"", + "ALTER TABLE \"app\".\"users\" RENAME COLUMN \"status\" TO \"email\"", + "ALTER TABLE \"app\".\"users\" RENAME COLUMN \"__tablepro_rename_1\" TO \"status\"", + ] + ); + assert_eq!( + sql(replaced), + [ + "ALTER TABLE \"app\".\"users\" DROP COLUMN \"status\"", + "ALTER TABLE \"app\".\"users\" RENAME COLUMN \"email\" TO \"status\"", + "ALTER TABLE \"app\".\"users\" ADD COLUMN \"email\" text", + ] + ); + } + #[test] fn a_cleared_default_and_comment_are_dropped() { let mut original = users(); @@ -671,15 +805,111 @@ mod tests { } #[test] - fn an_identity_change_waits_for_its_plan() { - let plan = alter(&users(), |draft| draft.columns[2].auto_increment = true); + fn identity_on_emits_setval_after_add() { + let mut table = users(); + table.table = TableRef::new(Some("App".to_owned()), "Users"); + table.columns[2].info.name = "Status".to_owned(); + + let plan = alter(&table, |draft| { + draft.columns[2].primary_key = true; + draft.columns[2].nullable = false; + draft.columns[2].auto_increment = true; + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE \"App\".\"Users\" DROP CONSTRAINT \"users_pkey\", ALTER COLUMN \"Status\" SET NOT \ + NULL, ALTER COLUMN \"Status\" ADD GENERATED BY DEFAULT AS IDENTITY, ADD PRIMARY KEY (\"id\", \ + \"Status\")", + "SELECT setval(pg_get_serial_sequence('\"App\".\"Users\"', 'Status'), \ + GREATEST(COALESCE(MAX(\"Status\")::bigint, 0), 0) + 1, false) FROM \"App\".\"Users\"", + ] + ); + } + + #[test] + fn identity_off_drops_the_identity_or_the_serial_default() { + let mut table = users(); + table.columns[0].info.is_auto_increment = true; + table.columns[0].attributes.identity = IdentityStyle::GeneratedAlways; + table.columns[2].info.is_auto_increment = true; + table.columns[2].attributes.identity = IdentityStyle::SequenceDefault; + + let plan = alter(&table, |draft| { + draft.columns[0].auto_increment = false; + draft.columns[2].auto_increment = false; + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE \"app\".\"users\" ALTER COLUMN \"id\" DROP IDENTITY, ALTER COLUMN \"status\" DROP \ + DEFAULT" + ] + ); + } + + #[test] + fn identity_takes_only_an_integer_without_a_default() { + let mut table = users(); + table.columns[2].info.default = ColumnDefault::Literal(Value::Int(1)); + let text = dialect().column_capabilities(&table, &table.columns[1]); + let defaulted = dialect().column_capabilities(&table, &table.columns[2]); + let plain = dialect().column_capabilities(&users(), &users().columns[2]); + + let retyped = alter(&users(), |draft| { + draft.columns[2].column_type = DraftType::Typed("text".to_owned()); + draft.columns[2].auto_increment = true; + }); + let given_a_default = alter(&users(), |draft| { + draft.columns[2].default = ColumnDefault::Literal(Value::Int(1)); + draft.columns[2].auto_increment = true; + }); + + assert_eq!( + text.alter_auto_increment, + Permission::Refused(RefusalReason::NotAnInteger) + ); + assert_eq!( + defaulted.alter_auto_increment, + Permission::Refused(RefusalReason::ConflictingAttribute("DEFAULT".to_owned())) + ); + assert_eq!(plain.alter_auto_increment, Permission::Allowed); + for (plan, reason) in [ + (retyped, RefusalReason::NotAnInteger), + ( + given_a_default, + RefusalReason::ConflictingAttribute("DEFAULT".to_owned()), + ), + ] { + assert_eq!( + plan, + Err(BuildDdlError::Refused { + column: "status".to_owned(), + operation: DdlOperation::AlterAutoIncrement, + reason, + }) + ); + } + } + + #[test] + fn a_key_the_catalogue_did_not_name_is_not_moved() { + let mut table = users(); + table.primary_key = None; + + let plan = alter(&table, |draft| { + draft.columns[2].primary_key = true; + draft.columns[2].nullable = false; + }); assert!( matches!( plan, Err(BuildDdlError::Refused { - operation: DdlOperation::AlterAutoIncrement, - reason: RefusalReason::EngineUnsupported, + operation: DdlOperation::AlterPrimaryKey, + reason: RefusalReason::UnreadableDefinition, .. }) ), diff --git a/linux/crates/drivers/postgres/tests/integration/ddl.rs b/linux/crates/drivers/postgres/tests/integration/ddl.rs index 8a3e0f3d58..5b21bbfc5f 100644 --- a/linux/crates/drivers/postgres/tests/integration/ddl.rs +++ b/linux/crates/drivers/postgres/tests/integration/ddl.rs @@ -1,23 +1,257 @@ //! Structure changes the PostgreSQL dialect plans, run against a real //! server. -use tablepro_core::Connection; -use tablepro_core::ddl::{CollationName, DraftType, TableDefinition, TableDraft, diff_table}; +use tablepro_core::ddl::{ + CollationName, DraftColumn, DraftType, IdentityStyle, PrimaryKeyConstraint, TableDefinition, TableDraft, diff_table, +}; use tablepro_core::meta::TableRef; +use tablepro_core::{Connection, WriteKind}; -use crate::support::{CALL, connect, setup, start, within}; +use crate::support::{CALL, apply, connect, scalar, setup, start, text, within}; fn kept() -> TableRef { TableRef::new(Some("public".to_owned()), "kept") } async fn read(connection: &dyn Connection) -> TableDefinition { + read_table(connection, &kept()).await +} + +async fn read_table(connection: &dyn Connection, table: &TableRef) -> TableDefinition { connection - .fetch_table_definition(&kept(), within(CALL)) + .fetch_table_definition(table, within(CALL)) .await .expect("the definition") } +/// Plan the edit against the table as read, run it as the structure tab +/// saves, and read the table again. +async fn change(connection: &dyn Connection, table: &TableRef, edit: impl Fn(&mut DraftColumn)) -> TableDefinition { + let before = read_table(connection, table).await; + let mut draft = TableDraft::from_definition(&before); + draft.columns.iter_mut().for_each(edit); + let dialect = connection.dialect(); + let plan = dialect + .ddl() + .expect("PostgreSQL plans its DDL") + .plan_alter_table(&before, &diff_table(&before, &draft)) + .expect("the change plans"); + connection + .write_batch(&plan.into_write_batch(table.clone()), within(CALL)) + .await + .expect("the change runs"); + read_table(connection, table).await +} + +fn type_of(definition: &TableDefinition, column: &str) -> String { + definition + .column(column) + .map(|column| column.info.column_type.name.clone()) + .unwrap_or_default() +} + +async fn value(connection: &dyn Connection, sql: &str) -> String { + text(&scalar(connection, sql).await) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn rename_and_retype_column_applies() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE kept (id int PRIMARY KEY, code varchar(10), qty smallint)", + "INSERT INTO kept VALUES (1, 'a', 5)", + ], + ) + .await; + + let after = change(connection.as_ref(), &kept(), |column| match column.name.as_str() { + "code" => { + column.name = "label".to_owned(); + column.column_type = DraftType::Typed("varchar(40)".to_owned()); + } + "qty" => { + column.name = "amount".to_owned(); + column.column_type = DraftType::Typed("bigint".to_owned()); + } + _ => {} + }) + .await; + + let names: Vec<&str> = after.columns.iter().map(|column| column.name()).collect(); + assert_eq!(names, ["id", "label", "amount"]); + assert_eq!(type_of(&after, "label"), "character varying(40)"); + assert_eq!(type_of(&after, "amount"), "bigint"); + assert_eq!( + value(connection.as_ref(), "SELECT label || '|' || amount FROM kept").await, + "a|5" + ); + connection.close().await.expect("a close"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn swap_column_names_applies() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE kept (id int PRIMARY KEY, a text, b integer, c boolean)", + "CREATE INDEX kept_by_a ON kept (a)", + "INSERT INTO kept VALUES (1, 'x', 2, true)", + ], + ) + .await; + + let after = change(connection.as_ref(), &kept(), |column| { + column.name = match column.name.as_str() { + "a" => "b", + "b" => "c", + "c" => "a", + other => other, + } + .to_owned(); + }) + .await; + + let names: Vec<&str> = after.columns.iter().map(|column| column.name()).collect(); + assert_eq!(names, ["id", "b", "c", "a"]); + assert_eq!(type_of(&after, "b"), "text"); + assert_eq!(type_of(&after, "c"), "integer"); + assert_eq!(type_of(&after, "a"), "boolean"); + assert_eq!( + value(connection.as_ref(), "SELECT b || '|' || c || '|' || a FROM kept").await, + "x|2|true" + ); + assert_eq!( + value(connection.as_ref(), "SELECT pg_get_indexdef('kept_by_a'::regclass)").await, + "CREATE INDEX kept_by_a ON public.kept USING btree (b)" + ); + connection.close().await.expect("a close"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn change_primary_key_to_composite_applies() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE kept (order_id int PRIMARY KEY, line int, sku text)", + "INSERT INTO kept VALUES (1, 1, 'a'), (2, 1, 'b')", + ], + ) + .await; + + let after = change(connection.as_ref(), &kept(), |column| { + if column.name == "line" { + column.primary_key = true; + column.nullable = false; + } + }) + .await; + + assert_eq!( + after.primary_key.as_ref().map(|key| key.columns.clone()), + Some(vec!["order_id".to_owned(), "line".to_owned()]) + ); + assert!(!after.column("line").expect("line").info.nullable); + apply( + connection.as_ref(), + WriteKind::RowChanges, + &["INSERT INTO kept VALUES (1, 2, 'c')"], + ) + .await + .expect("a second line of order 1"); + let repeated = apply( + connection.as_ref(), + WriteKind::RowChanges, + &["INSERT INTO kept VALUES (1, 1, 'd')"], + ) + .await; + assert!(repeated.is_err(), "the key does not hold the line"); + connection.close().await.expect("a close"); +} + +/// The table and column take capitals and a space, which +/// `pg_get_serial_sequence` reads only when the table is quoted and the +/// column is not. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn toggle_identity_on_populated_table_then_insert() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let table = TableRef::new(Some("public".to_owned()), "Ticket Log"); + setup( + connection.as_ref(), + &[ + "CREATE TABLE \"Ticket Log\" (\"Id\" int PRIMARY KEY, label text)", + "INSERT INTO \"Ticket Log\" VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd'), (5, 'e'), (42, 'f')", + ], + ) + .await; + + let numbered = change(connection.as_ref(), &table, |column| { + if column.name == "Id" { + column.auto_increment = true; + } + }) + .await; + apply( + connection.as_ref(), + WriteKind::RowChanges, + &["INSERT INTO \"Ticket Log\" (label) VALUES ('next')"], + ) + .await + .expect("an insert without an id"); + + let id = numbered.column("Id").expect("Id"); + assert!(id.info.is_auto_increment); + assert_eq!(id.attributes.identity, IdentityStyle::GeneratedByDefault); + assert_eq!( + value( + connection.as_ref(), + "SELECT \"Id\" FROM \"Ticket Log\" WHERE label = 'next'" + ) + .await, + "43" + ); + + let plain = change(connection.as_ref(), &table, |column| { + if column.name == "Id" { + column.auto_increment = false; + } + }) + .await; + + let id = plain.column("Id").expect("Id"); + assert!(!id.info.is_auto_increment); + assert_eq!(id.attributes.identity, IdentityStyle::None); + assert!( + apply( + connection.as_ref(), + WriteKind::RowChanges, + &["INSERT INTO \"Ticket Log\" (label) VALUES ('unnumbered')"], + ) + .await + .is_err(), + "the identity is still there" + ); + assert_eq!( + plain.primary_key, + Some(PrimaryKeyConstraint { + name: Some("Ticket Log_pkey".to_owned()), + columns: vec!["Id".to_owned()], + }) + ); + connection.close().await.expect("a close"); +} + /// SET DATA TYPE gives the column the new type's default collation /// unless the statement names one, so a type change on a text column /// has to name the one it had. diff --git a/linux/crates/drivers/sqlite/src/ddl.rs b/linux/crates/drivers/sqlite/src/ddl.rs index 5ff65e97bf..621b75ec1e 100644 --- a/linux/crates/drivers/sqlite/src/ddl.rs +++ b/linux/crates/drivers/sqlite/src/ddl.rs @@ -131,7 +131,9 @@ impl DdlDialect for SqliteDialect { self.quote_identifier(column.name()) )); } - for rename in &changes.renamed_columns { + // SQLite rewrites the table's indexes, triggers and views to the + // new name as each rename runs. + for rename in changes.ordered_renames(original) { statements.push(format!( "ALTER TABLE {qualified} RENAME COLUMN {} TO {}", self.quote_identifier(&rename.from), @@ -156,7 +158,7 @@ impl DdlDialect for SqliteDialect { .iter() .map(|index| self.create_index(&table, index)), ); - Ok(DdlPlan::from_sql(statements)) + DdlPlan::for_changes(changes, statements) } /// The CREATE statement SQLite stored, then the stored statements of @@ -409,6 +411,26 @@ mod tests { } } + #[test] + fn a_swap_steps_aside_through_a_temporary_name() { + let mut table = users(); + table.columns.push(column("name", "TEXT", false)); + + let plan = alter(&table, |draft| { + draft.columns[1].name = "Name".to_owned(); + draft.columns[2].name = "email".to_owned(); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE \"users\" RENAME COLUMN \"email\" TO \"__tablepro_rename_1\"", + "ALTER TABLE \"users\" RENAME COLUMN \"name\" TO \"email\"", + "ALTER TABLE \"users\" RENAME COLUMN \"__tablepro_rename_1\" TO \"Name\"", + ] + ); + } + #[test] fn a_required_column_without_a_default_cannot_be_added() { let plan = alter(&users(), |draft| { diff --git a/linux/crates/drivers/sqlite/tests/integration/ddl.rs b/linux/crates/drivers/sqlite/tests/integration/ddl.rs new file mode 100644 index 0000000000..e77af128d5 --- /dev/null +++ b/linux/crates/drivers/sqlite/tests/integration/ddl.rs @@ -0,0 +1,113 @@ +//! Structure changes the SQLite dialect plans, run against a database +//! file. + +use tablepro_core::ddl::{DraftColumn, SchemaObjectKind, TableDefinition, TableDraft, diff_table}; +use tablepro_core::meta::TableRef; +use tablepro_core::{Connection, Value}; + +use crate::support::{TestFile, apply, call, query}; + +fn kept() -> TableRef { + TableRef::unqualified("kept") +} + +async fn read(connection: &dyn Connection) -> TableDefinition { + connection.fetch_table_definition(&kept(), call()).await.unwrap() +} + +/// Plan the edit against the table as read, run it as the structure tab +/// saves, and read the table again. +async fn change(connection: &dyn Connection, edit: impl Fn(&mut DraftColumn)) -> TableDefinition { + let before = read(connection).await; + let mut draft = TableDraft::from_definition(&before); + draft.columns.iter_mut().for_each(edit); + let dialect = connection.dialect(); + let plan = dialect + .ddl() + .unwrap() + .plan_alter_table(&before, &diff_table(&before, &draft)) + .unwrap(); + connection + .write_batch(&plan.into_write_batch(kept()), call()) + .await + .unwrap(); + read(connection).await +} + +/// The stored statement with its quotes taken out: SQLite writes a +/// renamed name the way the rename spelled it. +fn dependent(definition: &TableDefinition, kind: SchemaObjectKind) -> String { + definition + .source + .as_ref() + .and_then(|source| source.dependents.iter().find(|object| object.kind == kind)) + .map(|object| object.sql.replace('"', "")) + .unwrap_or_default() +} + +/// SQLite rewrites the indexes and triggers that name a column as it +/// renames it, and a swap goes through a temporary name. +#[tokio::test] +async fn rename_column_updates_index_and_trigger() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY, email TEXT, name TEXT)", + "CREATE TABLE audit (note TEXT)", + "CREATE INDEX kept_by_email ON kept (email)", + "CREATE TRIGGER kept_audit AFTER INSERT ON kept BEGIN INSERT INTO audit VALUES (new.email); END", + "INSERT INTO kept VALUES (1, 'a@x', 'Ann')", + ], + ) + .await + .unwrap(); + + let renamed = change(connection.as_ref(), |column| { + if column.name == "email" { + column.name = "mail".to_owned(); + } + }) + .await; + let swapped = change(connection.as_ref(), |column| { + column.name = match column.name.as_str() { + "mail" => "name", + "name" => "mail", + other => other, + } + .to_owned(); + }) + .await; + apply( + connection.as_ref(), + &["INSERT INTO kept (id, name, mail) VALUES (2, 'b@x', 'Bo')"], + ) + .await + .unwrap(); + + let names = |definition: &TableDefinition| { + definition + .columns + .iter() + .map(|column| column.name().to_owned()) + .collect::>() + }; + assert_eq!(names(&renamed), ["id", "mail", "name"]); + assert!(dependent(&renamed, SchemaObjectKind::Index).contains("(mail)")); + assert!(dependent(&renamed, SchemaObjectKind::Trigger).contains("new.mail")); + assert_eq!(names(&swapped), ["id", "name", "mail"]); + assert!(dependent(&swapped, SchemaObjectKind::Index).contains("(name)")); + assert!(dependent(&swapped, SchemaObjectKind::Trigger).contains("new.name")); + let audit = query(connection.as_ref(), "SELECT note FROM audit ORDER BY rowid").await; + assert_eq!( + audit.rows, + [vec![Value::Text("a@x".to_owned())], vec![Value::Text("b@x".to_owned())]] + ); + let kept = query(connection.as_ref(), "SELECT name, mail FROM kept WHERE id = 1").await; + assert_eq!( + kept.rows, + [vec![Value::Text("a@x".to_owned()), Value::Text("Ann".to_owned())]] + ); + connection.close().await.unwrap(); +} diff --git a/linux/crates/drivers/sqlite/tests/integration/main.rs b/linux/crates/drivers/sqlite/tests/integration/main.rs index 6cf5e195d0..e023f9d73a 100644 --- a/linux/crates/drivers/sqlite/tests/integration/main.rs +++ b/linux/crates/drivers/sqlite/tests/integration/main.rs @@ -9,6 +9,8 @@ mod catalog; #[cfg(test)] mod conformance_engine; #[cfg(test)] +mod ddl; +#[cfg(test)] mod decode; #[cfg(test)] mod dialect; From 1d9f994c4104b16cdf9a1e2196c9fc0d00e07637 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:03:47 +0700 Subject: [PATCH 159/178] feat(linux): hold a primary key column NOT NULL in the Structure tab --- .../app/src/ui/structure_tab/columns.rs | 35 +++++++- linux/crates/app/src/ui/structure_tab/mod.rs | 8 +- .../crates/app/src/ui/structure_tab/tests.rs | 88 +++++++++++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/linux/crates/app/src/ui/structure_tab/columns.rs b/linux/crates/app/src/ui/structure_tab/columns.rs index 6e87b2caa2..709ff57486 100644 --- a/linux/crates/app/src/ui/structure_tab/columns.rs +++ b/linux/crates/app/src/ui/structure_tab/columns.rs @@ -61,6 +61,21 @@ pub(super) fn gate(widget: &impl IsA, permission: &Permission) { } } +/// A key column takes no NULL on any engine, so while the column is in +/// the key its Nullable switch is off and stays off. +fn hold_not_null(row: &adw::SwitchRow, key: bool) { + row.set_sensitive(!key); + let widget = row.upcast_ref::(); + if key { + let text = crate::i18n::gettext("A primary key column can't take NULL."); + row.set_tooltip_text(Some(&text)); + widget.update_property(&[gtk::accessible::Property::Description(&text)]); + } else { + row.set_tooltip_text(None); + widget.reset_property(gtk::AccessibleProperty::Description); + } +} + /// Mark a field whose text the engine's parser refused, or clear it. fn mark_invalid(row: &adw::EntryRow, error: Option<&str>) { match error { @@ -228,15 +243,33 @@ pub(super) fn build_column_expander_row( .build(); pk_row.set_widget_name(&format!("col-pk-{index}")); gate(&pk_row, &permissions.alter_primary_key); + // A refused nullability change keeps the reason it already shows. + let hold = permissions.alter_nullability.is_allowed(); + if hold { + hold_not_null(&nullable_row, col.primary_key); + } let sender_for_pk = sender.clone(); let suppress_for_pk = suppress_emit.clone(); + // The row owns the Nullable switch, so this holds it weakly. + let nullable_for_pk = nullable_row.downgrade(); pk_row.connect_active_notify(move |s| { if suppress_for_pk.get() { return; } + let key = s.is_active(); + if hold && let Some(nullable) = nullable_for_pk.upgrade() { + // The model sets NOT NULL with the key, so the switch only + // shows it. + if key { + suppress_for_pk.set(true); + nullable.set_active(false); + suppress_for_pk.set(false); + } + hold_not_null(&nullable, key); + } sender_for_pk.input(StructureTabInput::ColumnEdited { index, - field: ColumnField::PrimaryKey(s.is_active()), + field: ColumnField::PrimaryKey(key), }); }); row.add_row(&pk_row); diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index 4e92876d3b..ddb855c189 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -1096,9 +1096,11 @@ impl StructureModel { ColumnField::Nullable(nullable) => column.nullable = nullable, ColumnField::PrimaryKey(primary_key) => { column.primary_key = primary_key; - // Every engine numbers only a key column, and the switch - // only greys out, so the model drops it here. - if !primary_key { + if primary_key { + column.nullable = false; + } else { + // Every engine numbers only a key column, and the + // switch only greys out, so the model drops it here. column.auto_increment = false; } } diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index 7bb167d8bf..d6afd03338 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -648,3 +648,91 @@ fn an_edit_waits_for_the_read_after_a_committed_save() { harness.tab().send(StructureTabInput::AddColumn); assert_eq!(harness.column_names(), ["id", "column_1"]); } + +/// `id` keyed by `users_pkey`, and a nullable `score`. +fn keyed_users() -> TableDefinition { + TableDefinition { + columns: vec![integer_column("id", true), integer_column("score", false)], + primary_key: Some(PrimaryKeyConstraint { + name: Some("users_pkey".to_owned()), + columns: vec!["id".to_owned()], + }), + ..definition("id") + } +} + +fn preview(tab: &StructureTab) -> String { + let buffer = sql_buffer(tab); + buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string() +} + +#[gtk4::test] +fn renaming_existing_column_enables_save_and_previews_rename() { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(keyed_users())); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + drain_main_context(); + let imp = harness.tab().imp(); + assert!(!imp.save_button.is_sensitive()); + + let name = named_widget(harness.tab(), "col-name-1") + .downcast::() + .expect("the Name entry"); + assert!(name.is_sensitive(), "an existing column's name could not be edited"); + name.set_text("points"); + drain_main_context(); + + assert_eq!(harness.column_names(), ["id", "points"]); + assert!(imp.save_button.is_sensitive(), "a rename left Save off"); + assert_eq!(imp.pending_label.label(), "1 pending change"); + assert_eq!( + preview(harness.tab()), + "ALTER TABLE \"users\" RENAME COLUMN \"score\" TO \"points\";" + ); +} + +#[gtk4::test] +fn turning_the_primary_key_on_makes_the_column_not_null() { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(keyed_users())); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + drain_main_context(); + let switch = |name: &str| { + named_widget(harness.tab(), name) + .downcast::() + .expect("a switch row") + }; + let (key, nullable) = (switch("col-pk-1"), switch("col-nullable-1")); + assert!(!switch("col-nullable-0").is_sensitive(), "a key column could take NULL"); + assert!(nullable.is_sensitive() && nullable.is_active()); + + key.set_active(true); + drain_main_context(); + + assert!(!nullable.is_active() && !nullable.is_sensitive()); + assert!(nullable.tooltip_text().is_some_and(|tip| tip.contains("NULL"))); + let draft = |column: usize| { + harness + .tab() + .imp() + .model + .borrow() + .as_ref() + .map(|model| model.draft.borrow().columns[column].clone()) + .expect("the tab has a model") + }; + assert!(draft(1).primary_key && !draft(1).nullable); + assert_eq!( + preview(harness.tab()), + "ALTER TABLE \"users\" DROP CONSTRAINT \"users_pkey\", ALTER COLUMN \"score\" SET NOT NULL, ADD PRIMARY KEY \ + (\"id\", \"score\");" + ); + + key.set_active(false); + drain_main_context(); + + assert!(nullable.is_sensitive() && !nullable.is_active()); + assert!(!draft(1).primary_key); +} From 333967fba4a69ecfbd0aa5d4654356d304614975 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:03:47 +0700 Subject: [PATCH 160/178] docs(linux): record rename order and PostgreSQL identity in ADR 0010 --- linux/CHANGELOG.md | 3 +++ linux/docs/adding-drivers.md | 2 +- linux/docs/decisions/0010-ddl-plans-and-table-definitions.md | 5 ++++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 050b0c10e4..5bbb432ced 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -18,12 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Stop Saving in table and structure tabs, and in the dialog that waits for a save before closing. - Save Changes dialog on close that lists every tab with unsaved changes and saves only the ones left checked. - Logout waits while there are unsaved changes, and logout and suspend wait while a save is running. +- Auto-increment on and off for PostgreSQL columns in the Structure tab, numbering on from the largest existing value. ### Changed - Structure tab offers only the changes the database can make, and says why a control is off. - New rows in the table grid take a value for a primary key that is not auto-increment. - Show CREATE TABLE gives the server's own statement on MySQL, ClickHouse and SQLite. +- Structure tab sets a column NOT NULL when it joins the primary key. ### Fixed @@ -86,6 +88,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Edits made in a table tab while its save ran were dropped when the save finished. - Structure tab edits made while its save ran were lost when the table was read again. - Ctrl+S did nothing in a Structure tab. +- Structure tab could not save two columns swapping names, or a column taking the name of one being removed. ### Security diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index fa224945ec..2a28fba5a4 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -317,7 +317,7 @@ A run streams `ResultEvent`s into a `ResultSink`: | `column_capabilities` | A `Permission` per change to one column. Start from `ColumnCapabilities::standard`, which refuses a restatement that would drop a generated expression or an unmodeled attribute, a default on a numbered column, a drop of a column another table's foreign key points at, and any primary-key change while a foreign key points at the key. Add the engine's own refusals on top, each with its `RefusalReason`. An engine that writes the whole column again, as MySQL does for every change and SQL Server for a type or nullability change, refuses those changes where it cannot write the column back as it is. One that restates a generated column's expression checks `ColumnCapabilities::modeled` in place of `restatement`. | | `type_suggestions`, `default_column_type`, `referential_actions` | What the tab's type menu, new columns and foreign-key dialog offer. Every suggestion has to parse through `parse_type`. | | `plan_create_table(&TableDraft)` | Starts with `draft.check_create(self)?`. Foreign keys go inside CREATE TABLE. | -| `plan_alter_table(&TableDefinition, &TableChanges)` | Starts with `changes.check(self, original)?`, then writes the changes in the order the engine needs: one combined ALTER TABLE where the engine takes one, renames first, and every statement after a table rename against the new name. A table rename keeps the table in its schema. | +| `plan_alter_table(&TableDefinition, &TableChanges)` | Starts with `changes.check(self, original)?`, then writes the changes in the order the engine needs: one combined ALTER TABLE where the engine takes one, and every statement after a table rename against the new name. A table rename keeps the table in its schema. An engine that renames one column per statement takes them from `changes.ordered_renames(original)`, which runs a swap through a temporary name. It ends with `DdlPlan::for_changes`, which refuses a change that came to no statement. | | `plan_drop_table` | The default writes `DROP TABLE [IF EXISTS]

[CASCADE]`. | | `create_table_script` | The server's own CREATE statement where the engine keeps one, and `NoTableSource` when the definition carries none; the default writes it from the definition. | diff --git a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md index 5c4e5127f5..14fa29a124 100644 --- a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md +++ b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md @@ -33,7 +33,10 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` - MariaDB names a column's CHECK constraint after the column it was written on and keeps that name through a rename, so the check is placed by the column line of SHOW CREATE TABLE it ends. A check no line ends with refuses the restatement of every column without one. - The server reads a restated expression against the names the same ALTER gives, so a MySQL restatement whose CHECK, generated expression or default names a column renamed in the same change is refused, and the rename is saved on its own first. - On SQL Server, a type change whose new type cannot take the column's SPARSE or data mask is refused, as is any nullability or default change on a SPARSE column. -- Primary-key identity on PostgreSQL and SQLite table rebuilds are plans of their own, added to the same dialects later, and their capabilities are off until then. +- Engines that rename one column per statement (PostgreSQL, SQL Server, SQLite, ClickHouse) run the renames in an order where each new name is free, and a swap or longer cycle steps one column aside to `__tablepro_rename_` first. MySQL reads every clause of its one ALTER against the table as it was, so its renames need no order. PostgreSQL runs the drops before the renames when a save has both, so a column can take the name of one the same save drops. +- Turning numbering on for a PostgreSQL integer column adds a `BY DEFAULT` identity and moves its sequence past the largest key in the same transaction. Turning it off drops the identity, or the default of a serial column, whose sequence stays. +- A plan for a non-empty change writes at least one statement or refuses it, so a save never reports success for a table it left as it was. +- SQLite table rebuilds are a plan of their own, added to the same dialect later, and their capabilities are off until then. ## Alternatives considered From 3f5a00ac17122f35cc80b7d5f6f31ae4d68bac6f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:03:47 +0700 Subject: [PATCH 161/178] refactor(linux-grid): hand grid events to the tab at once --- linux/crates/app/src/ui/browse_tab/mod.rs | 15 ++++++--------- linux/crates/app/src/ui/browse_tab/tests.rs | 4 ++-- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 87022c4117..f253e5d014 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -139,18 +139,15 @@ impl BrowseTab { tab.collapse_top_bars_with(&filter_strip.widget); tab.connect_widgets(); - // Delivered once the signal that sent it returns: a commit - // refreshes its own row, which replaces the row's widgets, and - // the Tab that ended the edit still has to move focus from them. + // Handled at once: a tracked change redraws the cells bound to its + // row in place, so the cell that sent it is still there for the + // Tab that ended the edit to move focus from. let grid_sink: GridSink = { let tab = tab.downgrade(); Rc::new(move |msg| { - let tab = tab.clone(); - glib::spawn_future_local(async move { - if let Some(tab) = tab.upgrade() { - tab.send(BrowseTabInput::from(msg)); - } - }); + if let Some(tab) = tab.upgrade() { + tab.send(BrowseTabInput::from(msg)); + } }) }; let link_generation = init.session.link_generation(); diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index d4417b4ac8..4f0c1a288e 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -989,7 +989,7 @@ fn rename(id: i64, from: &str, to: &str) -> TrackerEdit { } #[gtk4::test] -fn a_commit_leaves_its_cell_in_the_window_for_the_tab_that_follows() { +fn a_commit_is_tracked_at_once_and_leaves_its_cell_in_the_window() { let mut script = serving_table(); script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); let harness = Harness::open(script, []); @@ -1006,7 +1006,7 @@ fn a_commit_leaves_its_cell_in_the_window_for_the_tab_that_follows() { cell.root().is_some(), "the commit took its own cell out of the window before Tab could move on" ); - wait_until(WAIT, || harness.tracker.pending_count() == 1).expect("the edit reaches the tracker"); + assert_eq!(harness.tracker.pending_count(), 1, "the edit waited for the main loop"); assert_eq!(cell_text(&cell), "Ada Lovelace"); assert!( has_class(&cell, "tp-cell-modified"), From 1c803d7804fc15c8173a5c8d9d8a2f504058c71d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:04:20 +0700 Subject: [PATCH 162/178] fix(linux): drop and re-add SQL Server defaults around a type change --- linux/CHANGELOG.md | 1 + linux/crates/drivers/mssql/src/ddl.rs | 148 ++++++++++++++++-- .../drivers/mssql/tests/integration/ddl.rs | 126 +++++++++++++++ .../0010-ddl-plans-and-table-definitions.md | 3 +- 4 files changed, 260 insertions(+), 18 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 5bbb432ced..1f8992dcc1 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -89,6 +89,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Structure tab edits made while its save ran were lost when the table was read again. - Ctrl+S did nothing in a Structure tab. - Structure tab could not save two columns swapping names, or a column taking the name of one being removed. +- Changing the type of a SQL Server column with a default failed. ### Security diff --git a/linux/crates/drivers/mssql/src/ddl.rs b/linux/crates/drivers/mssql/src/ddl.rs index 31716533ad..1cee30fe2b 100644 --- a/linux/crates/drivers/mssql/src/ddl.rs +++ b/linux/crates/drivers/mssql/src/ddl.rs @@ -65,8 +65,8 @@ const TYPE_SUGGESTIONS: &[&str] = &[ /// SQL Server changes a table one statement at a time, inside the /// write's transaction. A default is a constraint with a name of its -/// own, so a column whose default changes or goes away has that -/// constraint dropped by name first. +/// own, so a column whose type or default changes has that constraint +/// dropped by name first and added back after. impl DdlDialect for MssqlDialect { fn capabilities(&self) -> &DdlCapabilities { &CAPABILITIES @@ -175,6 +175,31 @@ impl DdlDialect for MssqlDialect { statements.extend(self.drop_default(&table, column)); statements.push(alter(format!("DROP COLUMN {}", self.quote_identifier(column.name())))); } + // A column bound to a default cannot change type, so its default + // goes first and comes back under the same name once the column + // and the columns added beside it are in place. + let mut defaults = Vec::new(); + for alteration in &changes.altered_columns { + let changed = alteration.changed; + let bound = alteration.original.attributes.default_constraint_name.as_deref(); + let dropped = bound.filter(|_| changed.column_type || changed.default); + statements.extend(dropped.and_then(|_| self.drop_default(&table, &alteration.original))); + if !(changed.default || dropped.is_some()) { + continue; + } + let draft = &alteration.draft; + let column_type = draft.column_type.column_type(self, &draft.name)?; + if let Some(default) = default_sql(self, &draft.name, &draft.default, &column_type)? { + let constraint = bound + .map(|name| format!("CONSTRAINT {} ", self.quote_identifier(name))) + .unwrap_or_default(); + defaults.push(alter(format!( + "ADD {constraint}DEFAULT {} FOR {}", + default_clause(&draft.default, &default), + self.quote_identifier(&draft.name) + ))); + } + } for rename in changes.ordered_renames(original) { let object = format!("{}.{}", qualified, self.quote_identifier(&rename.from)); statements.push(format!( @@ -187,11 +212,6 @@ impl DdlDialect for MssqlDialect { let draft = &alteration.draft; let name = self.quote_identifier(&draft.name); let changed = alteration.changed; - // A column bound to a default cannot change type, so the - // drop comes first. - if changed.default { - statements.extend(self.drop_default(&table, &alteration.original)); - } // ALTER COLUMN carries nothing forward: an omitted collation is // the database's, an omitted SPARSE is gone, and an omitted // NULL or NOT NULL is NULL. So every one is restated. @@ -221,15 +241,6 @@ impl DdlDialect for MssqlDialect { statements.push(alter(format!("ALTER COLUMN {name} ADD {}", masked_with(function)))); } } - if changed.default { - let column_type = draft.column_type.column_type(self, &draft.name)?; - if let Some(default) = default_sql(self, &draft.name, &draft.default, &column_type)? { - statements.push(alter(format!( - "ADD DEFAULT {} FOR {name}", - default_clause(&draft.default, &default) - ))); - } - } if changed.comment { let had_comment = alteration .original @@ -246,6 +257,7 @@ impl DdlDialect for MssqlDialect { statements.push(column_comment(&table, &column.name, false, Some(comment))); } } + statements.extend(defaults); if let Some(change) = changes.primary_key.as_ref().filter(|change| !change.to.is_empty()) { let constraint = old_key .map(|name| format!("CONSTRAINT {} ", self.quote_identifier(name))) @@ -611,7 +623,7 @@ mod tests { [ "ALTER TABLE [dbo].[orders] DROP CONSTRAINT [DF]]x]", "ALTER TABLE [dbo].[orders] ALTER COLUMN [status] bigint NOT NULL", - "ALTER TABLE [dbo].[orders] ADD DEFAULT (1) FOR [status]", + "ALTER TABLE [dbo].[orders] ADD CONSTRAINT [DF]]x] DEFAULT (1) FOR [status]", ] ); } @@ -759,6 +771,108 @@ mod tests { assert_eq!(sql(added), ["ALTER TABLE [dbo].[orders] ADD DEFAULT (N'x') FOR [note]"]); } + #[test] + fn type_change_with_default_orders_drop_alter_readd() { + let plan = alter(&orders(), |draft| { + draft.columns[1].column_type = DraftType::Typed("bigint".to_owned()); + draft.columns[1].primary_key = true; + draft.columns[1].nullable = false; + draft.columns.push(typed("added", "int")); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE [dbo].[orders] DROP CONSTRAINT [PK_orders]", + "ALTER TABLE [dbo].[orders] DROP CONSTRAINT [DF]]x]", + "ALTER TABLE [dbo].[orders] ALTER COLUMN [status] bigint NOT NULL", + "ALTER TABLE [dbo].[orders] ADD [added] int NULL", + "ALTER TABLE [dbo].[orders] ADD CONSTRAINT [DF]]x] DEFAULT ((0)) FOR [status]", + "ALTER TABLE [dbo].[orders] ADD CONSTRAINT [PK_orders] PRIMARY KEY ([id], [status])", + ] + ); + } + + #[test] + fn default_only_change_drops_then_adds() { + let plan = alter(&orders(), |draft| { + draft.columns[1].default = ColumnDefault::Literal(Value::Int(5)); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE [dbo].[orders] DROP CONSTRAINT [DF]]x]", + "ALTER TABLE [dbo].[orders] ADD CONSTRAINT [DF]]x] DEFAULT (5) FOR [status]", + ] + ); + } + + #[test] + fn clearing_default_only_drops() { + let plan = alter(&orders(), |draft| draft.columns[1].default = ColumnDefault::None); + + assert_eq!(sql(plan), ["ALTER TABLE [dbo].[orders] DROP CONSTRAINT [DF]]x]"]); + } + + #[test] + fn new_default_without_existing_constraint_adds_unnamed() { + let typed_only = alter(&orders(), |draft| { + draft.columns[2].column_type = DraftType::Typed("nvarchar(40)".to_owned()); + }); + let defaulted = alter(&orders(), |draft| { + draft.columns[2].default = ColumnDefault::Expression(SqlExpression::from_catalog_text("N'x'")); + }); + + assert_eq!( + sql(typed_only), + ["ALTER TABLE [dbo].[orders] ALTER COLUMN [note] nvarchar(40) NULL"] + ); + assert_eq!( + sql(defaulted), + ["ALTER TABLE [dbo].[orders] ADD DEFAULT (N'x') FOR [note]"] + ); + } + + #[test] + fn constraint_name_with_bracket_is_escaped() { + let mut table = orders(); + table.columns[1].attributes.default_constraint_name = Some("DF [a]] b".to_owned()); + + let plan = alter(&table, |draft| { + draft.columns[1].column_type = DraftType::Typed("smallint".to_owned()); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE [dbo].[orders] DROP CONSTRAINT [DF [a]]]] b]", + "ALTER TABLE [dbo].[orders] ALTER COLUMN [status] smallint NULL", + "ALTER TABLE [dbo].[orders] ADD CONSTRAINT [DF [a]]]] b] DEFAULT ((0)) FOR [status]", + ] + ); + } + + #[test] + fn a_renamed_column_takes_its_default_back_under_the_new_name() { + let plan = alter(&orders(), |draft| { + draft.columns[1].name = "state".to_owned(); + draft.columns[1].column_type = DraftType::Typed("bigint".to_owned()); + draft.columns[2].name = "status".to_owned(); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE [dbo].[orders] DROP CONSTRAINT [DF]]x]", + "EXEC sp_rename N'[dbo].[orders].[status]', N'state', N'COLUMN'", + "EXEC sp_rename N'[dbo].[orders].[note]', N'status', N'COLUMN'", + "ALTER TABLE [dbo].[orders] ALTER COLUMN [state] bigint NULL", + "ALTER TABLE [dbo].[orders] ADD CONSTRAINT [DF]]x] DEFAULT ((0)) FOR [state]", + ] + ); + } + #[test] fn a_swap_steps_aside_through_a_temporary_name() { let plan = alter(&orders(), |draft| { diff --git a/linux/crates/drivers/mssql/tests/integration/ddl.rs b/linux/crates/drivers/mssql/tests/integration/ddl.rs index d22d408800..fa3b04d3ea 100644 --- a/linux/crates/drivers/mssql/tests/integration/ddl.rs +++ b/linux/crates/drivers/mssql/tests/integration/ddl.rs @@ -1,6 +1,7 @@ //! Structure changes the SQL Server dialect plans, run against a real //! server. +use tablepro_core::column::ColumnDefault; use tablepro_core::ddl::{DdlPlan, DraftColumn, DraftType, TableDefinition, TableDraft, diff_table}; use tablepro_core::value::Value; use tablepro_core::{Connection, DriverError, WriteKind}; @@ -59,6 +60,21 @@ async fn text(connection: &dyn Connection, sql: &str) -> String { } } +/// The default constraint on `column` of dbo.kept, as `name definition`. +async fn default_constraint(connection: &dyn Connection, column: &str) -> String { + text( + connection, + &format!( + "SELECT COALESCE((SELECT d.name + ' ' + d.definition FROM sys.default_constraints AS d JOIN sys.columns \ + AS c ON c.object_id = d.parent_object_id AND c.column_id = d.parent_column_id WHERE d.parent_object_id \ + = OBJECT_ID(N'dbo.kept') AND c.name = N'{column}'), N'none')" + ), + ) + .await +} + +/// ALTER COLUMN gives the column the database's collation and drops +/// SPARSE and a data mask unless the plan restates them. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn nullability_change_keeps_column_collation() { @@ -274,3 +290,113 @@ async fn rename_column_with_period_and_bracket_applies() { assert_eq!(names(&renamed), ["id", "d.e]f"]); connection.close().await.expect("a close"); } + +/// SQL Server refuses to change the type of a column a default is bound +/// to, so the plan drops the default first and adds it back by name. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn type_change_on_column_with_default_succeeds() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &["CREATE TABLE dbo.kept (id int PRIMARY KEY, c int NULL CONSTRAINT DF_c DEFAULT 0)"], + ) + .await; + + let after = change(connection.as_ref(), |column| { + if column.name == "c" { + column.column_type = DraftType::Typed("bigint".to_owned()); + } + }) + .await; + + assert_eq!(type_of(&after, "c"), "bigint"); + assert_eq!( + after + .column("c") + .and_then(|column| column.attributes.default_constraint_name.clone()), + Some("DF_c".to_owned()) + ); + assert_eq!(default_constraint(connection.as_ref(), "c").await, "DF_c ((0))"); + connection.close().await.expect("a close"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn type_change_with_new_default_applies_new_default() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &["CREATE TABLE dbo.kept (id int PRIMARY KEY, c int NULL CONSTRAINT DF_c DEFAULT 0, d int NULL)"], + ) + .await; + + let after = change(connection.as_ref(), |column| match column.name.as_str() { + "c" => { + column.column_type = DraftType::Typed("nvarchar(20)".to_owned()); + column.default = ColumnDefault::Literal(Value::Text("none".to_owned())); + } + "d" => column.default = ColumnDefault::Literal(Value::Int(7)), + _ => {} + }) + .await; + apply( + connection.as_ref(), + WriteKind::RowChanges, + &["INSERT INTO dbo.kept (id) VALUES (1)"], + ) + .await + .expect("an insert that takes the defaults"); + + assert_eq!(type_of(&after, "c"), "nvarchar(20)"); + assert_eq!(default_constraint(connection.as_ref(), "c").await, "DF_c (N'none')"); + assert!(default_constraint(connection.as_ref(), "d").await.ends_with(" ((7))")); + assert_eq!( + text( + connection.as_ref(), + "SELECT c + N'|' + CAST(d AS nvarchar(20)) FROM dbo.kept" + ) + .await, + "none|7" + ); + connection.close().await.expect("a close"); +} + +/// A default renamed after the table was read is not where the plan +/// looks, so its drop fails and nothing before it stays applied. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn stale_constraint_name_rolls_back_whole_batch() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &["CREATE TABLE dbo.kept (id int PRIMARY KEY, gone int NULL, c int NULL CONSTRAINT DF_c DEFAULT 0)"], + ) + .await; + let before = read(connection.as_ref()).await; + let plan = plan(connection.as_ref(), &before, |draft| { + draft.columns.retain(|column| column.name != "gone"); + for column in &mut draft.columns { + if column.name == "c" { + column.column_type = DraftType::Typed("bigint".to_owned()); + } + } + }); + setup( + connection.as_ref(), + &["EXEC sp_rename N'dbo.DF_c', N'DF_moved', N'OBJECT'"], + ) + .await; + + let outcome = run(connection.as_ref(), plan).await; + + assert!(matches!(outcome, Err(DriverError::RolledBack { .. })), "{outcome:?}"); + let after = read(connection.as_ref()).await; + assert_eq!(names(&after), ["id", "gone", "c"]); + assert_eq!(type_of(&after, "c"), "int"); + assert_eq!(default_constraint(connection.as_ref(), "c").await, "DF_moved ((0))"); + connection.close().await.expect("a close"); +} diff --git a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md index 14fa29a124..94b2be3c69 100644 --- a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md +++ b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md @@ -13,7 +13,7 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` ## Rationale -**One change, the engine's order.** The tab edits a `TableDraft`. `diff_table` turns it into `TableChanges`: the table rename, the columns dropped, renamed, altered and added, the primary-key move, and the indexes and foreign keys dropped and added. `plan_alter_table` writes them in the order the engine needs: one combined ALTER TABLE on PostgreSQL, the foreign keys around one ALTER on MySQL, separate statements with the named default dropped first on SQL Server, native ALTER TABLE only on SQLite, one ALTER per change on ClickHouse. A `DdlPlan` carries the statements and the `ForeignKeyMode`, and becomes the `WriteBatch`. +**One change, the engine's order.** The tab edits a `TableDraft`. `diff_table` turns it into `TableChanges`: the table rename, the columns dropped, renamed, altered and added, the primary-key move, and the indexes and foreign keys dropped and added. `plan_alter_table` writes them in the order the engine needs: one combined ALTER TABLE on PostgreSQL, the foreign keys around one ALTER on MySQL, separate statements on SQL Server, with a named default dropped before its column changes and added back after, native ALTER TABLE only on SQLite, one ALTER per change on ClickHouse. A `DdlPlan` carries the statements and the `ForeignKeyMode`, and becomes the `WriteBatch`. **Refused before Save.** `DdlCapabilities` and `ColumnCapabilities` answer which changes the engine takes, each refusal with a `RefusalReason` the tab shows beside the control it turns off. A primary key another table's foreign key points at cannot move on any engine, and a column such a key references cannot be dropped. Every plan runs the same checks first, so a caller that skips the UI gets `BuildDdlError::Refused` or `Unsupported` rather than SQL the server rejects. @@ -33,6 +33,7 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` - MariaDB names a column's CHECK constraint after the column it was written on and keeps that name through a rename, so the check is placed by the column line of SHOW CREATE TABLE it ends. A check no line ends with refuses the restatement of every column without one. - The server reads a restated expression against the names the same ALTER gives, so a MySQL restatement whose CHECK, generated expression or default names a column renamed in the same change is refused, and the rename is saved on its own first. - On SQL Server, a type change whose new type cannot take the column's SPARSE or data mask is refused, as is any nullability or default change on a SPARSE column. +- SQL Server refuses to change the type of a column a default is bound to, so a type or default change drops the default constraint by the name the definition read and adds it back under that name once the column and any added columns are in place. A constraint renamed since the read fails the drop, and the transaction takes the whole save back. - Engines that rename one column per statement (PostgreSQL, SQL Server, SQLite, ClickHouse) run the renames in an order where each new name is free, and a swap or longer cycle steps one column aside to `__tablepro_rename_` first. MySQL reads every clause of its one ALTER against the table as it was, so its renames need no order. PostgreSQL runs the drops before the renames when a save has both, so a column can take the name of one the same save drops. - Turning numbering on for a PostgreSQL integer column adds a `BY DEFAULT` identity and moves its sequence past the largest key in the same transaction. Turning it off drops the identity, or the default of a serial column, whose sequence stays. - A plan for a non-empty change writes at least one statement or refuses it, so a save never reports success for a table it left as it was. From ab4a8b1027c6b03719d8680445e638e6bbd89f53 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:20:34 +0700 Subject: [PATCH 163/178] fix(linux): move the PostgreSQL identity's own sequence past its rows --- linux/crates/drivers/postgres/src/ddl.rs | 21 ++++++-- .../drivers/postgres/tests/integration/ddl.rs | 53 +++++++++++++++++-- .../0010-ddl-plans-and-table-definitions.md | 2 +- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/linux/crates/drivers/postgres/src/ddl.rs b/linux/crates/drivers/postgres/src/ddl.rs index fea8f556c2..50bf613a03 100644 --- a/linux/crates/drivers/postgres/src/ddl.rs +++ b/linux/crates/drivers/postgres/src/ddl.rs @@ -283,8 +283,11 @@ impl PgDialect { } /// A new identity starts at 1, so it is moved past the largest key - /// already there. `pg_get_serial_sequence` parses the table as SQL, - /// so it goes in quoted, and takes the column name as it is. + /// already there. A column can still own the sequence of a serial + /// default dropped earlier, which `pg_get_serial_sequence` may return + /// instead, so the identity's sequence is found by its internal + /// dependency on the column. The table goes in quoted, since + /// `regclass` parses it as SQL, and the column name as it is. fn identity_restart(&self, table: &TableRef, column: &str) -> Result { let text = |text: String| { SqlDialect::literal(self, &Value::Text(text), &ColumnType::untyped_text("text")).map_err(|error| { @@ -296,8 +299,12 @@ impl PgDialect { }; let qualified = self.qualify(table); Ok(format!( - "SELECT setval(pg_get_serial_sequence({}, {}), GREATEST(COALESCE(MAX({})::bigint, 0), 0) + 1, false) \ - FROM {qualified}", + "SELECT setval((SELECT d.objid::regclass FROM pg_catalog.pg_depend d \ + JOIN pg_catalog.pg_attribute a ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid \ + JOIN pg_catalog.pg_class s ON s.oid = d.objid \ + WHERE d.classid = 'pg_catalog.pg_class'::regclass AND d.refclassid = 'pg_catalog.pg_class'::regclass \ + AND d.deptype = 'i' AND s.relkind = 'S' AND d.refobjid = {}::regclass AND a.attname = {}), \ + GREATEST(COALESCE(MAX({})::bigint, 0), 0) + 1, false) FROM {qualified}", text(qualified.clone())?, text(column.to_owned())?, self.quote_identifier(column) @@ -822,7 +829,11 @@ mod tests { "ALTER TABLE \"App\".\"Users\" DROP CONSTRAINT \"users_pkey\", ALTER COLUMN \"Status\" SET NOT \ NULL, ALTER COLUMN \"Status\" ADD GENERATED BY DEFAULT AS IDENTITY, ADD PRIMARY KEY (\"id\", \ \"Status\")", - "SELECT setval(pg_get_serial_sequence('\"App\".\"Users\"', 'Status'), \ + "SELECT setval((SELECT d.objid::regclass FROM pg_catalog.pg_depend d JOIN \ + pg_catalog.pg_attribute a ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid JOIN \ + pg_catalog.pg_class s ON s.oid = d.objid WHERE d.classid = 'pg_catalog.pg_class'::regclass AND \ + d.refclassid = 'pg_catalog.pg_class'::regclass AND d.deptype = 'i' AND s.relkind = 'S' AND \ + d.refobjid = '\"App\".\"Users\"'::regclass AND a.attname = 'Status'), \ GREATEST(COALESCE(MAX(\"Status\")::bigint, 0), 0) + 1, false) FROM \"App\".\"Users\"", ] ); diff --git a/linux/crates/drivers/postgres/tests/integration/ddl.rs b/linux/crates/drivers/postgres/tests/integration/ddl.rs index 5b21bbfc5f..235e218c05 100644 --- a/linux/crates/drivers/postgres/tests/integration/ddl.rs +++ b/linux/crates/drivers/postgres/tests/integration/ddl.rs @@ -178,9 +178,8 @@ async fn change_primary_key_to_composite_applies() { connection.close().await.expect("a close"); } -/// The table and column take capitals and a space, which -/// `pg_get_serial_sequence` reads only when the table is quoted and the -/// column is not. +/// The table and column take capitals and a space, which `regclass` +/// reads only when the table is quoted and the column is not. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn toggle_identity_on_populated_table_then_insert() { @@ -252,6 +251,54 @@ async fn toggle_identity_on_populated_table_then_insert() { connection.close().await.expect("a close"); } +/// A serial turned off keeps its sequence, owned by the column, so the +/// identity added later is not the only sequence the column has. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn identity_after_serial_off_numbers_past_rows() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE kept (id serial PRIMARY KEY, label text)", + "INSERT INTO kept (label) VALUES ('a'), ('b'), ('c')", + ], + ) + .await; + let numbering = |on: bool| { + move |column: &mut DraftColumn| { + if column.name == "id" { + column.auto_increment = on; + } + } + }; + + let plain = change(connection.as_ref(), &kept(), numbering(false)).await; + assert_eq!( + plain.column("id").map(|id| id.attributes.identity), + Some(IdentityStyle::None) + ); + let numbered = change(connection.as_ref(), &kept(), numbering(true)).await; + apply( + connection.as_ref(), + WriteKind::RowChanges, + &["INSERT INTO kept (label) VALUES ('next')"], + ) + .await + .expect("an insert without an id"); + + assert_eq!( + numbered.column("id").map(|id| id.attributes.identity), + Some(IdentityStyle::GeneratedByDefault) + ); + assert_eq!( + value(connection.as_ref(), "SELECT id FROM kept WHERE label = 'next'").await, + "4" + ); + connection.close().await.expect("a close"); +} + /// SET DATA TYPE gives the column the new type's default collation /// unless the statement names one, so a type change on a text column /// has to name the one it had. diff --git a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md index 94b2be3c69..0d9dfcea7c 100644 --- a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md +++ b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md @@ -35,7 +35,7 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` - On SQL Server, a type change whose new type cannot take the column's SPARSE or data mask is refused, as is any nullability or default change on a SPARSE column. - SQL Server refuses to change the type of a column a default is bound to, so a type or default change drops the default constraint by the name the definition read and adds it back under that name once the column and any added columns are in place. A constraint renamed since the read fails the drop, and the transaction takes the whole save back. - Engines that rename one column per statement (PostgreSQL, SQL Server, SQLite, ClickHouse) run the renames in an order where each new name is free, and a swap or longer cycle steps one column aside to `__tablepro_rename_` first. MySQL reads every clause of its one ALTER against the table as it was, so its renames need no order. PostgreSQL runs the drops before the renames when a save has both, so a column can take the name of one the same save drops. -- Turning numbering on for a PostgreSQL integer column adds a `BY DEFAULT` identity and moves its sequence past the largest key in the same transaction. Turning it off drops the identity, or the default of a serial column, whose sequence stays. +- Turning numbering on for a PostgreSQL integer column adds a `BY DEFAULT` identity and moves its sequence past the largest key in the same transaction. Turning it off drops the identity, or the default of a serial column, whose sequence stays owned by the column. The identity's sequence is therefore found by its internal dependency on the column, not by `pg_get_serial_sequence`, which can return that serial sequence instead. - A plan for a non-empty change writes at least one statement or refuses it, so a save never reports success for a table it left as it was. - SQLite table rebuilds are a plan of their own, added to the same dialect later, and their capabilities are off until then. From e95d5b3f8958de63779d5486b9ecb4a96fd21d84 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:24:55 +0700 Subject: [PATCH 164/178] fix(linux): keep the Structure tab's key switches in step per engine --- linux/CHANGELOG.md | 3 +- .../app/src/ui/structure_tab/columns.rs | 66 +++++---- linux/crates/app/src/ui/structure_tab/mod.rs | 14 +- .../crates/app/src/ui/structure_tab/tests.rs | 140 ++++++++++++++---- linux/crates/core/src/ddl/ddl_capabilities.rs | 4 + linux/crates/core/src/ddl/tests.rs | 1 + linux/crates/drivers/clickhouse/src/ddl.rs | 1 + linux/crates/drivers/mssql/src/ddl.rs | 1 + linux/crates/drivers/mysql/src/ddl.rs | 2 + linux/crates/drivers/postgres/src/ddl.rs | 1 + linux/crates/drivers/sqlite/src/ddl.rs | 2 + 11 files changed, 172 insertions(+), 63 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 1f8992dcc1..387ee04cf2 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -25,7 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Structure tab offers only the changes the database can make, and says why a control is off. - New rows in the table grid take a value for a primary key that is not auto-increment. - Show CREATE TABLE gives the server's own statement on MySQL, ClickHouse and SQLite. -- Structure tab sets a column NOT NULL when it joins the primary key. +- Structure tab sets a column NOT NULL when it joins the primary key, and gives its nullability back when it leaves. +- Auto increment in the Structure tab no longer needs the primary key on PostgreSQL and SQL Server. ### Fixed diff --git a/linux/crates/app/src/ui/structure_tab/columns.rs b/linux/crates/app/src/ui/structure_tab/columns.rs index 709ff57486..20fa9da769 100644 --- a/linux/crates/app/src/ui/structure_tab/columns.rs +++ b/linux/crates/app/src/ui/structure_tab/columns.rs @@ -24,6 +24,9 @@ pub(super) struct ColumnRowContext { /// typed that did not parse. pub default_text: String, pub default_error: Option, + /// Numbering goes off with the key, on an engine that numbers only + /// a key column. + pub numbering_follows_key: bool, } /// The collapsed row's summary, such as `varchar(255) · NOT NULL · @@ -243,6 +246,24 @@ pub(super) fn build_column_expander_row( .build(); pk_row.set_widget_name(&format!("col-pk-{index}")); gate(&pk_row, &permissions.alter_primary_key); + + let auto_row = adw::SwitchRow::builder() + .title(crate::i18n::gettext("Auto increment")) + .active(col.auto_increment) + .build(); + auto_row.set_widget_name(&format!("col-auto-{index}")); + let follows_key = context.numbering_follows_key; + match &permissions.alter_auto_increment { + Permission::Allowed if follows_key => { + pk_row + .bind_property("active", &auto_row, "sensitive") + .sync_create() + .build(); + } + Permission::Allowed => {} + refused => gate(&auto_row, refused), + } + // A refused nullability change keeps the reason it already shows. let hold = permissions.alter_nullability.is_allowed(); if hold { @@ -250,47 +271,40 @@ pub(super) fn build_column_expander_row( } let sender_for_pk = sender.clone(); let suppress_for_pk = suppress_emit.clone(); - // The row owns the Nullable switch, so this holds it weakly. + // The row owns both switches, so this holds them weakly. let nullable_for_pk = nullable_row.downgrade(); + let auto_for_pk = auto_row.downgrade(); + // Given back when the column leaves the key. + let nullable_before_key = Cell::new(col.nullable); pk_row.connect_active_notify(move |s| { if suppress_for_pk.get() { return; } let key = s.is_active(); + sender_for_pk.input(StructureTabInput::ColumnEdited { + index, + field: ColumnField::PrimaryKey(key), + }); + // Each switch sends its own edit, so the model holds what the + // row shows. if hold && let Some(nullable) = nullable_for_pk.upgrade() { - // The model sets NOT NULL with the key, so the switch only - // shows it. if key { - suppress_for_pk.set(true); + nullable_before_key.set(nullable.is_active()); nullable.set_active(false); - suppress_for_pk.set(false); + } else { + nullable.set_active(nullable_before_key.get()); } hold_not_null(&nullable, key); } - sender_for_pk.input(StructureTabInput::ColumnEdited { - index, - field: ColumnField::PrimaryKey(key), - }); + if !key + && follows_key + && let Some(auto) = auto_for_pk.upgrade() + { + auto.set_active(false); + } }); row.add_row(&pk_row); - // A numbered column has to be a key on every engine, so the switch - // follows the primary key's where the engine allows the change. - let auto_row = adw::SwitchRow::builder() - .title(crate::i18n::gettext("Auto increment")) - .active(col.auto_increment) - .build(); - auto_row.set_widget_name(&format!("col-auto-{index}")); - match &permissions.alter_auto_increment { - Permission::Allowed => { - auto_row.set_sensitive(col.primary_key); - pk_row - .bind_property("active", &auto_row, "sensitive") - .sync_create() - .build(); - } - refused => gate(&auto_row, refused), - } let sender_for_auto = sender.clone(); let suppress_for_auto = suppress_emit.clone(); auto_row.connect_active_notify(move |s| { diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index ddb855c189..bbefecff81 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -691,6 +691,7 @@ impl StructureModel { dialect: self.dialect.clone(), default_text, default_error, + numbering_follows_key: !capabilities.auto_increment_outside_primary_key, }; list.append(&build_column_expander_row( i, @@ -1094,16 +1095,9 @@ impl StructureModel { }; } ColumnField::Nullable(nullable) => column.nullable = nullable, - ColumnField::PrimaryKey(primary_key) => { - column.primary_key = primary_key; - if primary_key { - column.nullable = false; - } else { - // Every engine numbers only a key column, and the - // switch only greys out, so the model drops it here. - column.auto_increment = false; - } - } + // The row's Nullable and Auto increment switches follow the + // key and send their own edits. + ColumnField::PrimaryKey(primary_key) => column.primary_key = primary_key, ColumnField::AutoIncrement(auto_increment) => column.auto_increment = auto_increment, ColumnField::Default(text) => match self.parse_default(&column, &text) { Ok(default) => { diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index d6afd03338..ca67b6e17e 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -5,7 +5,8 @@ use std::sync::Arc; use gtk4::subclass::prelude::ObjectSubclassIsExt; use tablepro_core::column::{ColumnDefault, ColumnType}; use tablepro_core::ddl::{ - ColumnAttributes, ColumnDefinition, ColumnKeyRoles, InboundForeignKey, PrimaryKeyConstraint, TableDefinition, + ColumnAttributes, ColumnDefinition, ColumnKeyRoles, DraftColumn, IdentityStyle, InboundForeignKey, + PrimaryKeyConstraint, TableDefinition, UnmodeledAttribute, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; @@ -692,20 +693,45 @@ fn renaming_existing_column_enables_save_and_previews_rename() { ); } -#[gtk4::test] -fn turning_the_primary_key_on_makes_the_column_not_null() { - let mut script = postgres_script(); - script.fetch_table_definition.always(FakeReply::ok(keyed_users())); +fn switch_row(tab: &StructureTab, name: &str) -> adw::SwitchRow { + named_widget(tab, name) + .downcast::() + .expect("a switch row") +} + +impl Harness { + fn draft_column(&self, index: usize) -> DraftColumn { + self.tab() + .imp() + .model + .borrow() + .as_ref() + .map(|model| model.draft.borrow().columns[index].clone()) + .expect("the tab has a model") + } +} + +fn open_edit(dialect: Arc, table: TableDefinition) -> Harness { + let mut script = ConnectionScript { + dialect, + ..ConnectionScript::default() + }; + script.fetch_table_definition.always(FakeReply::ok(table)); let harness = Harness::open(StructureMode::Edit, script, []); wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); drain_main_context(); - let switch = |name: &str| { - named_widget(harness.tab(), name) - .downcast::() - .expect("a switch row") - }; - let (key, nullable) = (switch("col-pk-1"), switch("col-nullable-1")); - assert!(!switch("col-nullable-0").is_sensitive(), "a key column could take NULL"); + harness +} + +#[gtk4::test] +fn the_primary_key_holds_a_column_not_null_until_it_leaves() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + let (key, nullable) = (switch_row(tab, "col-pk-1"), switch_row(tab, "col-nullable-1")); + assert!( + !switch_row(tab, "col-nullable-0").is_sensitive(), + "a key column could take NULL" + ); assert!(nullable.is_sensitive() && nullable.is_active()); key.set_active(true); @@ -713,19 +739,9 @@ fn turning_the_primary_key_on_makes_the_column_not_null() { assert!(!nullable.is_active() && !nullable.is_sensitive()); assert!(nullable.tooltip_text().is_some_and(|tip| tip.contains("NULL"))); - let draft = |column: usize| { - harness - .tab() - .imp() - .model - .borrow() - .as_ref() - .map(|model| model.draft.borrow().columns[column].clone()) - .expect("the tab has a model") - }; - assert!(draft(1).primary_key && !draft(1).nullable); + assert!(harness.draft_column(1).primary_key && !harness.draft_column(1).nullable); assert_eq!( - preview(harness.tab()), + preview(tab), "ALTER TABLE \"users\" DROP CONSTRAINT \"users_pkey\", ALTER COLUMN \"score\" SET NOT NULL, ADD PRIMARY KEY \ (\"id\", \"score\");" ); @@ -733,6 +749,78 @@ fn turning_the_primary_key_on_makes_the_column_not_null() { key.set_active(false); drain_main_context(); - assert!(nullable.is_sensitive() && !nullable.is_active()); - assert!(!draft(1).primary_key); + assert!(nullable.is_sensitive() && nullable.is_active()); + assert!(nullable.tooltip_text().is_none()); + assert!(!harness.draft_column(1).primary_key && harness.draft_column(1).nullable); + assert_eq!(preview(tab), "-- No pending changes."); +} + +/// The Nullable switch shows what the draft holds, so a column whose +/// nullability cannot be restated stays nullable in the key, and the +/// preview says what holds the save. +#[gtk4::test] +fn a_held_nullable_column_joins_the_key_as_it_is() { + let mut table = keyed_users(); + table.columns[1].attributes.unmodeled = vec![UnmodeledAttribute::new("STORAGE EXTERNAL")]; + let harness = open_edit(postgres(), table); + let tab = harness.tab(); + let nullable = switch_row(tab, "col-nullable-1"); + assert!(!nullable.is_sensitive()); + + switch_row(tab, "col-pk-1").set_active(true); + drain_main_context(); + + assert!(nullable.is_active()); + assert!( + nullable + .tooltip_text() + .is_some_and(|tip| tip.contains("STORAGE EXTERNAL")) + ); + assert!(harness.draft_column(1).primary_key && harness.draft_column(1).nullable); + assert_eq!(preview(tab), "-- Primary key columns must be NOT NULL: score"); +} + +#[gtk4::test] +fn a_postgres_identity_keeps_numbering_outside_the_key() { + let mut table = keyed_users(); + table.columns[0].info.is_auto_increment = true; + table.columns[0].attributes.identity = IdentityStyle::GeneratedAlways; + let harness = open_edit(postgres(), table); + let tab = harness.tab(); + let auto = switch_row(tab, "col-auto-0"); + assert!( + switch_row(tab, "col-auto-1").is_sensitive(), + "a column outside the key could not number itself" + ); + + switch_row(tab, "col-pk-0").set_active(false); + drain_main_context(); + + assert!(auto.is_active() && auto.is_sensitive()); + assert!(harness.draft_column(0).auto_increment && !harness.draft_column(0).primary_key); + assert_eq!(preview(tab), "ALTER TABLE \"users\" DROP CONSTRAINT \"users_pkey\";"); +} + +#[gtk4::test] +fn a_mysql_column_stops_numbering_as_it_leaves_the_key() { + let mysql: Arc = Arc::new(drivers_mysql::MySqlDialect::new( + drivers_mysql::MySqlFlavour::MySql, + &ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + )); + let mut table = keyed_users(); + for column in &mut table.columns { + column.info.column_type = mysql.parse_type("int").expect("a MySQL type"); + } + table.columns[0].info.is_auto_increment = true; + let harness = open_edit(mysql, table); + let tab = harness.tab(); + let auto = switch_row(tab, "col-auto-0"); + assert!(auto.is_active()); + assert!(!switch_row(tab, "col-auto-1").is_sensitive()); + + switch_row(tab, "col-pk-0").set_active(false); + drain_main_context(); + + assert!(!auto.is_active() && !auto.is_sensitive()); + assert!(!harness.draft_column(0).auto_increment); } diff --git a/linux/crates/core/src/ddl/ddl_capabilities.rs b/linux/crates/core/src/ddl/ddl_capabilities.rs index 0d2459d207..2cb535f410 100644 --- a/linux/crates/core/src/ddl/ddl_capabilities.rs +++ b/linux/crates/core/src/ddl/ddl_capabilities.rs @@ -12,6 +12,10 @@ pub struct DdlCapabilities { pub alter_column_default: bool, pub alter_primary_key: bool, pub alter_auto_increment: bool, + /// Whether a column numbers itself outside the primary key, as a + /// PostgreSQL identity or a SQL Server IDENTITY does. Where it cannot, + /// numbering goes off as the column leaves the key. + pub auto_increment_outside_primary_key: bool, pub create_index: bool, pub drop_index: bool, pub expression_index_keys: bool, diff --git a/linux/crates/core/src/ddl/tests.rs b/linux/crates/core/src/ddl/tests.rs index 53f4e4b3b1..a9bf3c02ba 100644 --- a/linux/crates/core/src/ddl/tests.rs +++ b/linux/crates/core/src/ddl/tests.rs @@ -418,6 +418,7 @@ fn every_change() -> DdlCapabilities { alter_column_default: true, alter_primary_key: true, alter_auto_increment: true, + auto_increment_outside_primary_key: true, create_index: true, drop_index: true, expression_index_keys: true, diff --git a/linux/crates/drivers/clickhouse/src/ddl.rs b/linux/crates/drivers/clickhouse/src/ddl.rs index 33a5104578..70d56f8555 100644 --- a/linux/crates/drivers/clickhouse/src/ddl.rs +++ b/linux/crates/drivers/clickhouse/src/ddl.rs @@ -24,6 +24,7 @@ static CAPABILITIES: DdlCapabilities = DdlCapabilities { alter_column_default: true, alter_primary_key: false, alter_auto_increment: false, + auto_increment_outside_primary_key: false, // A data-skipping index needs a TYPE and a GRANULARITY the index // dialog does not ask for. create_index: false, diff --git a/linux/crates/drivers/mssql/src/ddl.rs b/linux/crates/drivers/mssql/src/ddl.rs index 1cee30fe2b..f26f4742d0 100644 --- a/linux/crates/drivers/mssql/src/ddl.rs +++ b/linux/crates/drivers/mssql/src/ddl.rs @@ -23,6 +23,7 @@ static CAPABILITIES: DdlCapabilities = DdlCapabilities { alter_primary_key: true, // IDENTITY cannot be added to or taken from an existing column. alter_auto_increment: false, + auto_increment_outside_primary_key: true, create_index: true, drop_index: true, // An index key is a column; an expression needs a computed one. diff --git a/linux/crates/drivers/mysql/src/ddl.rs b/linux/crates/drivers/mysql/src/ddl.rs index 3e93961b60..b5a415d7d1 100644 --- a/linux/crates/drivers/mysql/src/ddl.rs +++ b/linux/crates/drivers/mysql/src/ddl.rs @@ -26,6 +26,8 @@ const fn capabilities(expression_index_keys: bool) -> DdlCapabilities { alter_column_default: true, alter_primary_key: true, alter_auto_increment: true, + // An AUTO_INCREMENT column has to be a key. + auto_increment_outside_primary_key: false, create_index: true, drop_index: true, expression_index_keys, diff --git a/linux/crates/drivers/postgres/src/ddl.rs b/linux/crates/drivers/postgres/src/ddl.rs index 50bf613a03..026925f576 100644 --- a/linux/crates/drivers/postgres/src/ddl.rs +++ b/linux/crates/drivers/postgres/src/ddl.rs @@ -22,6 +22,7 @@ static CAPABILITIES: DdlCapabilities = DdlCapabilities { alter_column_default: true, alter_primary_key: true, alter_auto_increment: true, + auto_increment_outside_primary_key: true, create_index: true, drop_index: true, expression_index_keys: true, diff --git a/linux/crates/drivers/sqlite/src/ddl.rs b/linux/crates/drivers/sqlite/src/ddl.rs index 621b75ec1e..23393692b3 100644 --- a/linux/crates/drivers/sqlite/src/ddl.rs +++ b/linux/crates/drivers/sqlite/src/ddl.rs @@ -23,6 +23,8 @@ static CAPABILITIES: DdlCapabilities = DdlCapabilities { alter_column_default: false, alter_primary_key: false, alter_auto_increment: false, + // AUTOINCREMENT belongs to the INTEGER PRIMARY KEY. + auto_increment_outside_primary_key: false, create_index: true, drop_index: true, expression_index_keys: true, From d252845789ce12294fdcbd419230c17c5311f650 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:26:42 +0700 Subject: [PATCH 165/178] fix(linux): hold a case-only rename while another column has the name --- linux/crates/core/src/ddl/order_renames.rs | 33 +++++++++++++++-- .../drivers/postgres/tests/integration/ddl.rs | 36 +++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/linux/crates/core/src/ddl/order_renames.rs b/linux/crates/core/src/ddl/order_renames.rs index 90accef6c1..c6a53a015d 100644 --- a/linux/crates/core/src/ddl/order_renames.rs +++ b/linux/crates/core/src/ddl/order_renames.rs @@ -19,7 +19,9 @@ const TEMPORARY_PREFIX: &str = "__tablepro_rename_"; /// /// Names match without regard to case: MySQL, SQL Server and SQLite /// compare column names that way, and the extra step this can cost a -/// case-sensitive engine changes nothing it ends with. +/// case-sensitive engine changes nothing it ends with. A rename that +/// changes only case waits while another column holds the name in some +/// case, which only a case-sensitive engine allows. pub fn order_renames<'a>( renames: &[ColumnRename], occupied: impl IntoIterator, @@ -75,9 +77,10 @@ pub fn order_renames<'a>( ordered } -/// A rename into its own name in other case frees what it takes. +/// A rename into its own name in other case frees what it takes, unless +/// another column holds that name too. fn is_free(held: &Held, rename: &ColumnRename) -> bool { - same(&rename.from, &rename.to) || !held.contains(&rename.to) + !held.contains(&rename.to) || (same(&rename.from, &rename.to) && held.count(&rename.to) == 1) } /// A rename whose new name another pending rename starts from, and so on @@ -131,6 +134,10 @@ impl Held { fn contains(&self, name: &str) -> bool { self.counts.contains_key(&fold(name)) } + + fn count(&self, name: &str) -> usize { + self.counts.get(&fold(name)).copied().unwrap_or_default() + } } #[cfg(test)] @@ -224,6 +231,26 @@ mod tests { ); } + #[test] + fn order_renames_keeps_names_that_differ_only_in_case_apart() { + let freed_first = order_renames( + &[rename("Name", "name"), rename("name", "old")], + ["id", "Name", "name"], + [], + ); + let swapped = order_renames(&[rename("Name", "name"), rename("name", "Name")], ["Name", "name"], []); + + assert_eq!(steps(&freed_first), [("name", "old"), ("Name", "name")]); + assert_eq!( + steps(&swapped), + [ + ("Name", "__tablepro_rename_1"), + ("name", "Name"), + ("__tablepro_rename_1", "name"), + ] + ); + } + #[test] fn order_renames_leaves_a_name_still_held_to_the_server() { let ordered = order_renames(&[rename("a", "b"), rename("c", "d")], ["a", "b", "c"], []); diff --git a/linux/crates/drivers/postgres/tests/integration/ddl.rs b/linux/crates/drivers/postgres/tests/integration/ddl.rs index 235e218c05..0d8a76e814 100644 --- a/linux/crates/drivers/postgres/tests/integration/ddl.rs +++ b/linux/crates/drivers/postgres/tests/integration/ddl.rs @@ -134,6 +134,42 @@ async fn swap_column_names_applies() { connection.close().await.expect("a close"); } +/// PostgreSQL keeps `Name` and `name` apart, so `Name` takes its +/// lowercase name only once `name` has moved off it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn case_only_rename_waits_for_its_name() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE kept (id int PRIMARY KEY, \"Name\" text, name integer)", + "INSERT INTO kept VALUES (1, 'x', 2)", + ], + ) + .await; + + let after = change(connection.as_ref(), &kept(), |column| { + column.name = match column.name.as_str() { + "Name" => "name", + "name" => "old", + other => other, + } + .to_owned(); + }) + .await; + + let names: Vec<&str> = after.columns.iter().map(|column| column.name()).collect(); + assert_eq!(names, ["id", "name", "old"]); + assert_eq!(type_of(&after, "name"), "text"); + assert_eq!( + value(connection.as_ref(), "SELECT name || '|' || old FROM kept").await, + "x|2" + ); + connection.close().await.expect("a close"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn change_primary_key_to_composite_applies() { From 42c3d03336ed59dfd67a379b43dbac64512f1efe Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:26:42 +0700 Subject: [PATCH 166/178] build(linux): sort the app's dev-dependencies --- linux/crates/app/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml index 2beab9b40b..06e852c9cd 100644 --- a/linux/crates/app/Cargo.toml +++ b/linux/crates/app/Cargo.toml @@ -64,12 +64,12 @@ gettext-rs = { version = "0.8", features = ["gettext-system"] } sqlformat = "0.5" [dev-dependencies] +proptest.workspace = true tablepro-session = { path = "../session", features = ["fakes"] } tablepro-test-fixtures = { path = "../test-fixtures" } tablepro-test-support = { path = "../test-support" } -tokio = { workspace = true, features = ["macros", "rt", "test-util"] } -proptest.workspace = true tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "rt", "test-util"] } # tokio-postgres logs through `log`, so the logging tests drive that # bridge the way it does. log = "0.4" From b46af91a0c62040a1c9923877d4b25e9536fc497 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:35:59 +0700 Subject: [PATCH 167/178] style(linux-grid): drop comments that restate the code --- linux/crates/app/src/ui/grid/column_drawing.rs | 1 - linux/crates/app/src/ui/grid/grid_cell.rs | 2 -- linux/crates/app/src/ui/grid/row_object.rs | 1 - 3 files changed, 4 deletions(-) diff --git a/linux/crates/app/src/ui/grid/column_drawing.rs b/linux/crates/app/src/ui/grid/column_drawing.rs index 62548c2613..555920bff4 100644 --- a/linux/crates/app/src/ui/grid/column_drawing.rs +++ b/linux/crates/app/src/ui/grid/column_drawing.rs @@ -37,7 +37,6 @@ impl ColumnDrawing { } } - /// The value as the tracker has it, pending edits and state included. pub fn draw(&self, cell: &GridCell, row: &RowObject) { let (value, pending_classes) = self.tab_ctx.bind_state(row, cell.column()); draw_cell(cell, &value, &pending_classes, cell.lock().is_none(), self.auto_filled); diff --git a/linux/crates/app/src/ui/grid/grid_cell.rs b/linux/crates/app/src/ui/grid/grid_cell.rs index 4fbb1a5043..e7801f5495 100644 --- a/linux/crates/app/src/ui/grid/grid_cell.rs +++ b/linux/crates/app/src/ui/grid/grid_cell.rs @@ -137,7 +137,6 @@ impl GridCell { let _ = self.imp().drawing.set(drawing); } - /// Show `row`, and draw it again each time its revision moves. pub(super) fn bind(&self, row: &RowObject) { let lock = match self.imp().drawing.get() { Some(drawing) => drawing.lock(row.id()), @@ -163,7 +162,6 @@ impl GridCell { self.take_edit(); } - /// Draw the bound row as the tracker has it now. pub(super) fn render(&self) { #[cfg(test)] self.imp().renders.set(self.imp().renders.get() + 1); diff --git a/linux/crates/app/src/ui/grid/row_object.rs b/linux/crates/app/src/ui/grid/row_object.rs index f0dabb5bb0..869ca8fe25 100644 --- a/linux/crates/app/src/ui/grid/row_object.rs +++ b/linux/crates/app/src/ui/grid/row_object.rs @@ -91,7 +91,6 @@ impl RowObject { self.imp().cells.get()?.get(column) } - /// Tell the cells bound to this row that its pending state changed. pub(crate) fn bump_revision(&self) { let revision = &self.imp().revision; revision.set(revision.get().wrapping_add(1)); From d04585842c704938cb6baefc7ae74ff91058965c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:36:19 +0700 Subject: [PATCH 168/178] fix(linux-grid): cancel an open edit when a redraw changes its value --- linux/crates/app/src/ui/browse_tab/tests.rs | 23 +++++ linux/crates/app/src/ui/grid/cell_editor.rs | 6 +- .../crates/app/src/ui/grid/column_drawing.rs | 10 +- .../crates/app/src/ui/grid/column_factory.rs | 91 ++++++++++++++++++- linux/crates/app/src/ui/grid/grid_cell.rs | 20 +++- 5 files changed, 142 insertions(+), 8 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 4f0c1a288e..d25cd4b15f 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -1091,6 +1091,29 @@ fn rejected_edit_restores_cell_display() { assert_eq!(harness.tracker.pending_count(), 0); } +#[gtk4::test] +fn undo_under_an_open_editor_leaves_nothing_pending() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let cell = grid_cell_of(harness.tab(), 0, 1); + harness.apply_delta(&harness.tracker.apply(rename(0, "Ada", "Ada Lovelace"))); + crate::ui::grid::enter_edit_mode(&cell); + let editor = cell.editor().expect("a text cell"); + + harness.tab().send(BrowseTabInput::Undo); + editor.stop_editing(false); + + assert_eq!( + harness.tracker.pending_count(), + 0, + "closing the editor sent the undone value back" + ); + assert_eq!(cell_text(&cell), "Ada"); +} + #[gtk4::test] fn error_flash_sets_and_clears_on_the_failed_row() { let mut script = serving_table(); diff --git a/linux/crates/app/src/ui/grid/cell_editor.rs b/linux/crates/app/src/ui/grid/cell_editor.rs index 5392905d89..16641f8fba 100644 --- a/linux/crates/app/src/ui/grid/cell_editor.rs +++ b/linux/crates/app/src/ui/grid/cell_editor.rs @@ -128,13 +128,9 @@ impl CellEditor { } } - /// An edit in progress keeps what was typed; a row redrawn under it - /// changes what Escape goes back to. pub fn set_text(&self, s: &str) { self.label_widget().set_text(s); - if !self.is_editing() { - self.entry_widget().set_text(s); - } + self.entry_widget().set_text(s); } pub fn set_selectable(&self, selectable: bool) { diff --git a/linux/crates/app/src/ui/grid/column_drawing.rs b/linux/crates/app/src/ui/grid/column_drawing.rs index 555920bff4..10665f06f9 100644 --- a/linux/crates/app/src/ui/grid/column_drawing.rs +++ b/linux/crates/app/src/ui/grid/column_drawing.rs @@ -64,7 +64,15 @@ fn draw_cell(grid_cell: &GridCell, value: &Value, pending_classes: &[&'static st return; }; if let Some(editor) = content.downcast_ref::() { - editor.set_text(&text); + // An edit opened on a value the row no longer has would, once it + // ended, send that value back or leave it on screen. An edit on + // the same value keeps what was typed. + if grid_cell.record_drawn_text(&text) && (editor.is_editing() || grid_cell.is_editing()) { + grid_cell.cancel_edit(); + } + if !editor.is_editing() { + editor.set_text(&text); + } // A locked cell's text is there to be read and copied, as a // read-only column's is. editor.set_selectable(!editable); diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index 7b5e7b200b..911f7b6cdb 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -306,10 +306,11 @@ mod tests { use super::build_column_view; use crate::services::change_tracker::RowKey; use crate::services::tab_tracker_handle::TabTrackerHandle; - use crate::services::tracker_delta::VisualChange; + use crate::services::tracker_delta::{TrackerDelta, VisualChange}; use crate::services::tracker_edit::TrackerEdit; use crate::test_support::{descendants, drain_main_context, wait_until}; use crate::ui::grid::cell_editability::column_takes_edits; + use crate::ui::grid::display_text::editable_null_sentinel; use crate::ui::grid::{ CellEditor, CellPreset, ColumnWidthBinding, GridCell, GridMsg, GridSink, RowObject, TabGridContext, enter_edit_mode, @@ -468,6 +469,17 @@ mod tests { fn take_messages(&self) -> Vec { self.messages.take() } + + /// What the tab does with a tracker change. + fn redraw(&self, delta: &TrackerDelta) { + for position in 0..self.store.n_items() { + if let Some(row) = self.store.item(position).and_downcast::() + && row.id().tracker_key().is_some_and(|key| delta.rows.contains(&key)) + { + row.bump_revision(); + } + } + } } impl Drop for Grid { @@ -682,6 +694,83 @@ mod tests { assert_eq!(editor.entry().text(), "typed", "the redraw overwrote what was typed"); } + fn name_rows(first: Value) -> Grid { + Grid::open( + vec![named("id", "integer", true), named("name", "text", false)], + vec![vec![Value::Int(1), first], vec![Value::Int(2), Value::Text("B".into())]], + ) + } + + /// Row 1's name edited from `from` to `to`, its editor opened on + /// `to`, then the edit undone under the open editor. + fn undo_under_open_editor(from: Value, to: Value) -> (Grid, GridCell, CellEditor) { + let grid = name_rows(from.clone()); + let cell = grid.cell_of(1, 1); + grid.redraw(&grid.tracker.apply(TrackerEdit::Cell { + row_key: key(1), + col: 1, + original: from, + value: to, + })); + enter_edit_mode(&cell); + let editor = cell.editor().expect("a text cell"); + assert!(editor.is_editing(), "the cell did not open for editing"); + + grid.redraw(&grid.tracker.undo().expect("an edit to undo")); + + (grid, cell, editor) + } + + #[gtk4::test] + fn escape_after_an_undo_under_the_editor_sends_nothing() { + let (grid, cell, editor) = undo_under_open_editor(Value::Text("A".into()), Value::Text("X".into())); + assert!( + !editor.is_editing(), + "the editor stayed open on a value the row no longer has" + ); + + editor.stop_editing(false); + cell.unbind(); + + assert_eq!(edited_rows(&grid.take_messages()), vec![]); + assert_eq!(editor.text(), "A"); + assert_eq!(grid.tracker.pending_count(), 0); + } + + #[gtk4::test] + fn escape_after_undoing_an_edit_of_null_sends_nothing() { + let (grid, _cell, editor) = undo_under_open_editor(Value::Null, Value::Text("hello".into())); + + editor.stop_editing(false); + + assert_eq!(edited_rows(&grid.take_messages()), vec![]); + assert_eq!(editor.text().as_str(), editable_null_sentinel()); + } + + #[gtk4::test] + fn enter_after_an_undo_under_the_editor_shows_the_undone_value() { + let (grid, _cell, editor) = undo_under_open_editor(Value::Text("A".into()), Value::Text("X".into())); + + editor.stop_editing(true); + + assert_eq!(edited_rows(&grid.take_messages()), vec![]); + assert_eq!(editor.text(), "A", "the cell shows an edit the tracker no longer has"); + } + + #[gtk4::test] + fn escape_on_a_null_cell_drawn_again_under_its_editor_sends_nothing() { + let grid = name_rows(Value::Null); + let cell = grid.cell_of(1, 1); + enter_edit_mode(&cell); + let editor = cell.editor().expect("a text cell"); + + cell.row().expect("a bound row").bump_revision(); + assert!(editor.is_editing(), "a redraw of the same value closed the editor"); + editor.stop_editing(false); + + assert_eq!(edited_rows(&grid.take_messages()), vec![]); + } + #[gtk4::test] fn unbind_disconnects_revision_handler() { let columns = vec![named("id", "integer", true), named("name", "text", false)]; diff --git a/linux/crates/app/src/ui/grid/grid_cell.rs b/linux/crates/app/src/ui/grid/grid_cell.rs index e7801f5495..4ee89af1d4 100644 --- a/linux/crates/app/src/ui/grid/grid_cell.rs +++ b/linux/crates/app/src/ui/grid/grid_cell.rs @@ -31,6 +31,9 @@ mod imp { pub lock: Cell>, pub drawing: OnceCell>, pub binding: RefCell>, + /// The label cannot say what was drawn once an edit has cleared + /// a NULL sentinel from it. + pub drawn_text: RefCell, #[cfg(test)] pub renders: Cell, } @@ -151,6 +154,12 @@ impl GridCell { /// for is no longer this cell's. pub(super) fn unbind(&self) { self.imp().binding.take(); + self.cancel_edit(); + } + + /// The snapshot goes first, so the editor closing sends nothing. + pub(super) fn cancel_edit(&self) { + self.take_edit(); if let Some(editor) = self.editor() && editor.is_editing() { @@ -159,7 +168,16 @@ impl GridCell { if let Some(popover) = self.take_popover() { popover.popdown(); } - self.take_edit(); + } + + /// `true` when `text` differs from what the cell drew before it. + pub(super) fn record_drawn_text(&self, text: &str) -> bool { + let mut drawn = self.imp().drawn_text.borrow_mut(); + if drawn.as_str() == text { + return false; + } + text.clone_into(&mut drawn); + true } pub(super) fn render(&self) { From b9975a9195b3c027ec51d4b3ed9054751dd22881 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:36:19 +0700 Subject: [PATCH 169/178] test(linux-grid): cover the refused edit redraw and unbind directly --- linux/crates/app/src/ui/browse_tab/tests.rs | 22 ++++++ .../crates/app/src/ui/grid/column_factory.rs | 79 +++++-------------- 2 files changed, 41 insertions(+), 60 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index d25cd4b15f..e8cd0b7364 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -1091,6 +1091,28 @@ fn rejected_edit_restores_cell_display() { assert_eq!(harness.tracker.pending_count(), 0); } +#[gtk4::test] +fn refused_edit_restores_cell_display() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let _shown = Shown::new(harness.tab()); + // A saved row's key, which the tab refuses to change in place. + let cell = grid_cell_of(harness.tab(), 0, 0); + let row = cell.row().expect("a bound row"); + cell.editor().expect("a text cell").set_text("7"); + + harness.tab().send(BrowseTabInput::GridCellEdited { + row, + column: 0, + input: "7".to_owned(), + }); + + assert_eq!(cell_text(&cell), "0", "the cell kept the edit the tab refused"); + assert_eq!(harness.tracker.pending_count(), 0); +} + #[gtk4::test] fn undo_under_an_open_editor_leaves_nothing_pending() { let mut script = serving_table(); diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index 911f7b6cdb..67386b3378 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -694,6 +694,25 @@ mod tests { assert_eq!(editor.entry().text(), "typed", "the redraw overwrote what was typed"); } + #[gtk4::test] + fn unbind_disconnects_revision_handler() { + let grid = two_text_rows(); + let cell = grid.cell_of(2, 1); + let row = cell.row().expect("a bound row"); + let bound_renders = cell.render_count(); + row.bump_revision(); + assert_eq!(cell.render_count(), bound_renders + 1, "a bound cell did not redraw"); + + cell.unbind(); + row.bump_revision(); + + assert_eq!( + cell.render_count(), + bound_renders + 1, + "a cell drew again for a row it no longer shows" + ); + } + fn name_rows(first: Value) -> Grid { Grid::open( vec![named("id", "integer", true), named("name", "text", false)], @@ -771,66 +790,6 @@ mod tests { assert_eq!(edited_rows(&grid.take_messages()), vec![]); } - #[gtk4::test] - fn unbind_disconnects_revision_handler() { - let columns = vec![named("id", "integer", true), named("name", "text", false)]; - let rows: Vec> = (0..300) - .map(|id| vec![Value::Int(id), Value::Text(format!("row {id}"))]) - .collect(); - let result = QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - rows.clone(), - ); - let tracker = TabTrackerHandle::default(); - let (sink, _messages) = recorder(); - let (view, _selection) = build_column_view( - &result, - persisted(&rows), - &columns, - sink, - None, - false, - tracked(&tracker), - ); - let scrolled = gtk::ScrolledWindow::builder().child(&view).build(); - let window = gtk::Window::new(); - window.set_default_size(600, 400); - window.set_child(Some(&scrolled)); - window.present(); - // Not the first row, which the view may keep bound as its focus. - let name_cell = || { - descendants(&view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|cell| cell.column() == 1 && cell.row().is_some_and(|row| row.key() == Some(&key(3)))) - }; - let mut found = None; - wait_until(WAIT, || { - found = name_cell(); - found.is_some() - }) - .expect("the fourth row is drawn"); - let cell = found.expect("a cell"); - let row = cell.row().expect("a bound row"); - let bound_renders = cell.render_count(); - row.bump_revision(); - assert_eq!(cell.render_count(), bound_renders + 1, "a bound cell did not redraw"); - - view.scroll_to(299, None, gtk::ListScrollFlags::NONE, None); - wait_until(WAIT, || cell.row().as_ref() != Some(&row)).expect("the fourth row's cell was never let go"); - let unbound_renders = cell.render_count(); - row.bump_revision(); - drain_main_context(); - - assert_eq!( - cell.render_count(), - unbound_renders, - "a cell drew again for a row it no longer shows" - ); - window.destroy(); - drain_main_context(); - } - fn width_binding(tasks: &tablepro_session::runtime::Tasks, root: &tempfile::TempDir) -> ColumnWidthBinding { ColumnWidthBinding { store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), tasks), From 996d907f93f4eebf28d961e7347d91100dfac8d4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:45:10 +0700 Subject: [PATCH 170/178] test(linux): expect a SQL Server default to keep its constraint name --- .../drivers/mssql/tests/integration/metadata.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/linux/crates/drivers/mssql/tests/integration/metadata.rs b/linux/crates/drivers/mssql/tests/integration/metadata.rs index eb1d94cc36..8dddfd177e 100644 --- a/linux/crates/drivers/mssql/tests/integration/metadata.rs +++ b/linux/crates/drivers/mssql/tests/integration/metadata.rs @@ -248,15 +248,9 @@ async fn alter_default_on_column_with_named_constraint() { .await .expect("the definition"); let state = state.column("state").expect("state"); - assert!( - state - .attributes - .default_constraint_name - .as_deref() - .is_some_and(|name| name != "DF]state"), - "{:?}", - state.attributes.default_constraint_name - ); + // The new default goes back under the old constraint's name, so a + // named constraint keeps its name through the change. + assert_eq!(state.attributes.default_constraint_name.as_deref(), Some("DF]state")); assert_eq!(state.info.default, ColumnDefault::Literal(Value::Int(1))); } From c19191a2343814a371e98074f4c61dd02bb126da Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 02:45:30 +0700 Subject: [PATCH 171/178] docs(linux): file the Format and file-reading entries under Fixed --- linux/CHANGELOG.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index b31eeb4a43..15068c67ff 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. +- Format could split `+=`, `b'01'` or `@@version`, or detach a MySQL function from its parenthesis, and change the statement. +- Opening a SQL, CSV or connections file from a pipe or a device never finished, or read past its size limit. - PostgreSQL statement timeouts showed as a plain server error when the server's messages were not in English. - MySQL and MariaDB connections that verify the certificate did not trust certificate authorities installed on the system. - SQL Server money and smallmoney in the SQL editor and query results showed as approximate floats. @@ -87,8 +89,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Disconnecting or switching connections offered no way to save pending changes first. - Edits made while a switch to another connection was connecting were closed without asking. - A save whose answer was lost with the connection showed as failed, and saving again could apply it twice. -- Edits made in a table tab while its save ran were dropped when the save finished. -- Structure tab edits made while its save ran were lost when the table was read again. +- Edits made in a table or Structure tab while its save ran were lost when the save finished. - Ctrl+S did nothing in a Structure tab. - Structure tab could not save two columns swapping names, or a column taking the name of one being removed. - Changing the type of a SQL Server column with a default failed. @@ -96,9 +97,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - CSV export did not neutralise formulas in values of a type TablePro does not recognise. -- Format could put a space inside `+=`, `b'01'` or `@@version`, or between a MySQL function such as `GROUP_CONCAT` and its parenthesis, and change what the statement does. -- Opening a pipe or a device as a SQL file never finished, or read past the 8 MB limit. -- Importing a CSV or a connections file from a pipe or a device never finished, a CSV could read past its size limit, and a connections file had none. - SQL run in the editor, a typed filter or a column default, and the values in them, reached the journal at debug level. - Column types and defaults typed in the Structure tab reached the SQL after a text filter instead of the database's own parser. - Changing a SQL Server column's type or nullability in the Structure tab removed its data mask. From c6913f975f64b85c4f21a7b7d89f1c43c91d858c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 03:06:22 +0700 Subject: [PATCH 172/178] fix(linux-grid): measure edits made during a save against what it wrote --- linux/CHANGELOG.md | 1 + .../crates/app/src/services/change_tracker.rs | 182 ++++++++++++++++-- 2 files changed, 164 insertions(+), 19 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 15068c67ff..8d6264f8eb 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cells of a table without a primary key opened for editing, then dropped what was typed. - A save that failed on a new row did not flash that row. - Undo after editing a cell twice restored the fetched value instead of the first edit. +- An edit made while a save ran was dropped when it set the cell back to the value it was read with. - A save that deleted a row and added a new one with the same unique value failed. - The window title showed no unsaved-changes bullet for pending structure changes. - `real` values showed extra digits in the grid, copies and exports, such as 0.10000000149011612 for 0.1. diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index 531eef7ba6..f93889fc36 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -104,6 +104,17 @@ pub enum StatementSource { Delete { row_key: RowKey }, } +/// The save that is running: where its edits end, and the value it +/// writes to each cell. +#[derive(Debug)] +struct InFlightSave { + snapshot: SaveSnapshot, + /// Until the save settles, each of these cells holds either this + /// value or the one that was fetched, so no later edit to it can be + /// called a return to the cell's value. + written: HashMap<(RowKey, usize), Value>, +} + #[derive(Debug, Default)] pub struct TabChangeTracker { drafts: Vec, @@ -121,8 +132,7 @@ pub struct TabChangeTracker { /// The table and columns the tab last read, which a save is planned /// against. target: Option, - /// Where the save in flight ends. - saving: Option, + saving: Option, /// A save that may have landed. Saving again could apply it twice, /// so nothing saves until the tab is reloaded or discarded. outcome_unknown: Option, @@ -161,7 +171,7 @@ impl TabChangeTracker { /// delete in the save stays as it is until the save settles; a cell /// edit is a new value, which the next save writes. pub fn edit_is_in_flight(&self, edit: &TrackerEdit) -> bool { - let Some(snapshot) = self.saving else { + let Some(snapshot) = self.saving.as_ref().map(|save| save.snapshot) else { return false; }; let draft_in_flight = |draft_id: u64| { @@ -209,12 +219,9 @@ impl TabChangeTracker { } fn track_cell_edit(&mut self, row_key: RowKey, col: usize, original: Value, value: Value) -> TrackerDelta { - let before = self.updates.get(&(row_key.clone(), col)).cloned(); - // The running save is writing `before`, so even a value equal to - // the fetched one is a change the next save has to write back. - let in_flight = before - .as_ref() - .is_some_and(|edit| self.saving.is_some_and(|snapshot| snapshot.includes(edit.touched))); + let key = (row_key.clone(), col); + let before = self.updates.get(&key).cloned(); + let in_flight = self.is_being_written(&key); let original = before.as_ref().map_or(original, |edit| edit.original.clone()); let after = (in_flight || value != original).then(|| CellEdit { original, @@ -295,8 +302,16 @@ impl TabChangeTracker { Some((draft.values.get(col)?.clone(), draft.touched)) } + fn is_being_written(&self, cell: &(RowKey, usize)) -> bool { + self.saving.as_ref().is_some_and(|save| save.written.contains_key(cell)) + } + fn put_cell_edit(&mut self, row_key: &RowKey, col: usize, edit: Option) -> TrackerDelta { let key = (row_key.clone(), col); + // An edit equal to the fetched value is kept only while a save + // writes the cell. Undo and redo wait for the save, so they find + // such an edit no longer changing anything. + let edit = edit.filter(|edit| edit.pending != edit.original || self.is_being_written(&key)); match edit { Some(edit) => self.updates.insert(key, edit), None => self.updates.remove(&key), @@ -632,7 +647,13 @@ impl TabChangeTracker { return Ok(None); } let snapshot = SaveSnapshot(self.next_touch); - self.saving = Some(snapshot); + let written = self + .updates + .iter() + .filter(|((row_key, _), _)| matches!(row_key, RowKey::Persisted(_)) && !self.deletes.contains_key(row_key)) + .map(|(cell, edit)| (cell.clone(), edit.pending.clone())) + .collect(); + self.saving = Some(InFlightSave { snapshot, written }); Ok(Some(SaveBatch { table: target.table, columns: target.columns, @@ -642,15 +663,28 @@ impl TabChangeTracker { } /// End the save `snapshot` began. A commit drops what it wrote and - /// keeps every edit made while it ran; a rollback keeps everything; - /// an unknown outcome keeps everything and refuses the next save. + /// keeps every edit made while it ran, now measured against what the + /// save wrote; a rollback keeps everything, measured against what was + /// fetched; an unknown outcome keeps everything and refuses the next + /// save. pub(crate) fn settle_save(&mut self, outcome: &SaveOutcome, snapshot: SaveSnapshot) -> TrackerDelta { - if self.saving == Some(snapshot) { - self.saving = None; - } + let written = self + .saving + .take_if(|save| save.snapshot == snapshot) + .map(|save| save.written) + .unwrap_or_default(); match outcome { - SaveOutcome::Committed { .. } => self.drop_saved(snapshot), - SaveOutcome::RolledBack(_) => self.delta(Vec::new(), Vec::new()), + SaveOutcome::Committed { .. } => { + let mut delta = self.drop_saved(snapshot); + delta.rows.extend(self.rebase(written, true)); + dedup(&mut delta.rows); + delta.pending_count = self.pending_count(); + delta + } + SaveOutcome::RolledBack(_) => { + let rows = self.rebase(written, false); + self.delta(rows, Vec::new()) + } SaveOutcome::Unknown(cause) => { self.mark_outcome_unknown(cause.clone()); self.delta(Vec::new(), Vec::new()) @@ -662,6 +696,28 @@ impl TabChangeTracker { self.outcome_unknown = Some(cause); } + /// Edits made to cells a settled save wrote. After a commit the cell + /// holds what the save wrote, which becomes the value an edit is + /// measured against; after a rollback it still holds what was + /// fetched. Either way an edit back to that value is no change. + fn rebase(&mut self, written: HashMap<(RowKey, usize), Value>, committed: bool) -> Vec { + let mut rows = Vec::new(); + for (cell, value) in written { + let Some(edit) = self.updates.get_mut(&cell) else { + continue; + }; + if committed { + edit.original = value; + } + if edit.pending == edit.original { + self.updates.remove(&cell); + rows.push(cell.0); + } + } + dedup(&mut rows); + rows + } + fn drop_saved(&mut self, snapshot: SaveSnapshot) -> TrackerDelta { let mut rows = Vec::new(); let mut visual = Vec::new(); @@ -693,12 +749,16 @@ impl TabChangeTracker { // and the saved ones are now the rows themselves. self.undo.clear(); self.redo.clear(); - let mut seen = HashSet::new(); - rows.retain(|row_key| seen.insert(row_key.clone())); + dedup(&mut rows); self.delta(rows, visual) } } +fn dedup(rows: &mut Vec) { + let mut seen = HashSet::new(); + rows.retain(|row_key| seen.insert(row_key.clone())); +} + /// The tracker's changes in the form a save plans from, with the row /// each one came from. #[derive(Debug, Clone, Default)] @@ -1509,6 +1569,90 @@ mod tests { assert_eq!(untouched.state, CellState::Clean); } + fn saving_on(columns: Vec) -> TabChangeTracker { + let mut t = TabChangeTracker::default(); + t.set_target(SaveTarget { + table: tablepro_core::meta::TableRef::unqualified("t"), + columns, + }); + t + } + + fn committed() -> SaveOutcome { + SaveOutcome::Committed { rows: Some(1) } + } + + fn rolled_back() -> SaveOutcome { + SaveOutcome::RolledBack(crate::workspace::RollbackCause::Driver( + tablepro_core::DriverError::server("no"), + )) + } + + /// Each UPDATE's assignments, by column name. + fn assignments(t: &TabChangeTracker, columns: &[ColumnInfo]) -> Vec<(String, Value)> { + t.change_set(columns) + .unwrap() + .changes + .changes + .into_iter() + .flat_map(|change| match change { + RowChange::Update(update) => update.assignments, + other => panic!("only updates were made: {other:?}"), + }) + .map(|(col, value)| (columns[col].name.clone(), value)) + .collect() + } + + #[test] + fn a_return_to_the_fetched_value_during_a_save_is_written_after_it() { + let columns = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(columns.clone()); + edit(&mut t, 1, "a", "b"); + let batch = t.begin_save().unwrap().unwrap(); + edit(&mut t, 1, "a", "c"); + edit(&mut t, 1, "a", "a"); + + let _ = t.settle_save(&committed(), batch.snapshot); + + // The save left b in the database, so the a typed while it ran + // is a change. + assert_eq!(assignments(&t, &columns), vec![("name".to_owned(), text("a"))]); + assert_eq!(t.cell_state(&rk(&[Value::Int(1)]), 1), CellState::Modified); + } + + #[test] + fn a_return_to_the_fetched_value_during_a_save_that_rolls_back_is_no_change() { + let columns = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(columns.clone()); + edit(&mut t, 1, "a", "b"); + let batch = t.begin_save().unwrap().unwrap(); + edit(&mut t, 1, "a", "c"); + edit(&mut t, 1, "a", "a"); + + let delta = t.settle_save(&rolled_back(), batch.snapshot); + + assert_eq!(delta.pending_count, 0); + assert_eq!(t.pending_count(), 0); + assert_eq!(delta.rows, vec![rk(&[Value::Int(1)])]); + } + + #[test] + fn an_edit_that_outlives_a_save_is_measured_against_what_it_wrote() { + let columns = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(columns.clone()); + edit(&mut t, 1, "a", "b"); + let batch = t.begin_save().unwrap().unwrap(); + edit(&mut t, 1, "a", "c"); + let _ = t.settle_save(&committed(), batch.snapshot); + + // The page read after the commit holds b. + edit(&mut t, 1, "b", "a"); + assert_eq!(assignments(&t, &columns), vec![("name".to_owned(), text("a"))]); + + edit(&mut t, 1, "b", "b"); + assert_eq!(t.pending_count(), 0, "a return to what the save wrote stayed pending"); + } + #[test] fn an_older_flash_timer_does_not_clear_a_newer_flash() { let mut t = TabChangeTracker::default(); From 104948752725268595afdfb99cafe2f5bc2bdc85 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 03:11:41 +0700 Subject: [PATCH 173/178] fix(linux-grid): keep pending edits on their columns when columns change --- linux/CHANGELOG.md | 1 + .../crates/app/src/services/change_tracker.rs | 278 +++++++++++++++++- .../app/src/services/column_relayout.rs | 200 +++++++++++++ linux/crates/app/src/services/mod.rs | 1 + .../app/src/services/tab_tracker_handle.rs | 7 +- linux/crates/app/src/services/undo_op.rs | 62 ++++ linux/crates/app/src/ui/browse_tab/mod.rs | 42 ++- linux/crates/app/src/ui/browse_tab/tests.rs | 152 ++++++++++ .../app/src/workspace/save_rejection.rs | 30 ++ .../app/src/workspace/test_workspace.rs | 2 +- linux/po/POTFILES.in | 1 + 11 files changed, 764 insertions(+), 12 deletions(-) create mode 100644 linux/crates/app/src/services/column_relayout.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 8d6264f8eb..9f8819eb08 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -55,6 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A save that failed on a new row did not flash that row. - Undo after editing a cell twice restored the fetched value instead of the first edit. - An edit made while a save ran was dropped when it set the cell back to the value it was read with. +- Refresh or a structure change while edits were pending could save an edit into another column or row. - A save that deleted a row and added a new one with the same unique value failed. - The window title showed no unsaved-changes bullet for pending structure changes. - `real` values showed extra digits in the grid, copies and exports, such as 0.10000000149011612 for 0.1. diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index f93889fc36..60009957ad 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -11,6 +11,7 @@ use tablepro_core::save::{ChangeSet, RowChange, RowUpdate}; use tablepro_core::{ColumnInfo, Value}; use super::cell_overlay::CellOverlay; +use super::column_relayout::{ColumnRelayout, Misfit}; use super::save_batch::{SaveBatch, SaveTarget}; use super::touch::Touch; use super::tracker_delta::{TrackerDelta, VisualChange}; @@ -115,6 +116,16 @@ struct InFlightSave { written: HashMap<(RowKey, usize), Value>, } +/// A pending change the table's columns no longer have a place for. +/// Saved by its old position it would write another column or row, and +/// dropped it would be lost unseen, so it stays pending and refuses +/// every save until it is discarded. +#[derive(Debug, Clone, PartialEq)] +struct SetAside { + touched: Touch, + misfit: Misfit, +} + #[derive(Debug, Default)] pub struct TabChangeTracker { drafts: Vec, @@ -130,8 +141,9 @@ pub struct TabChangeTracker { // cleared by that older timer. error_row_gen: u64, /// The table and columns the tab last read, which a save is planned - /// against. + /// against. Every column position the tracker holds is one in these. target: Option, + set_aside: Vec, saving: Option, /// A save that may have landed. Saving again could apply it twice, /// so nothing saves until the tab is reloaded or discarded. @@ -140,7 +152,7 @@ pub struct TabChangeTracker { impl TabChangeTracker { pub fn pending_count(&self) -> usize { - self.drafts.len() + self.deletes.len() + self.updates.len() + self.drafts.len() + self.deletes.len() + self.updates.len() + self.set_aside.len() } /// An edit the running save holds back comes back as a redraw of its @@ -438,6 +450,7 @@ impl TabChangeTracker { self.drafts.clear(); self.deletes.clear(); self.updates.clear(); + self.set_aside.clear(); self.undo.clear(); self.redo.clear(); self.delta(rows, visual) @@ -610,8 +623,110 @@ impl TabChangeTracker { }) } - pub(crate) fn set_target(&mut self, target: SaveTarget) { - self.target = Some(target); + /// Columns read again move every pending change to the column of + /// the same name. A change they have no place for is set aside. + pub(crate) fn set_target(&mut self, target: SaveTarget) -> TrackerDelta { + let columns = target.columns.clone(); + match self.target.replace(target) { + Some(previous) if previous.columns != columns => { + self.relayout(&ColumnRelayout::new(&previous.columns, &columns)) + } + _ => self.delta(Vec::new(), Vec::new()), + } + } + + fn relayout(&mut self, layout: &ColumnRelayout<'_>) -> TrackerDelta { + let mut rows = Vec::new(); + let mut visual = Vec::new(); + let set_aside = self.set_aside.len(); + for ((row_key, col), edit) in std::mem::take(&mut self.updates) { + match layout.key(&row_key).and_then(|()| layout.cell(col, &edit.pending)) { + Ok(moved) => { + self.updates.insert((row_key.clone(), moved), edit); + } + Err(misfit) => self.set_aside.push(SetAside { + touched: edit.touched, + misfit, + }), + } + rows.push(row_key); + } + for (row_key, mut mark) in std::mem::take(&mut self.deletes) { + match layout.key(&row_key) { + Ok(()) => { + mark.original_values = layout.fetched(&mark.original_values); + self.deletes.insert(row_key.clone(), mark); + } + Err(misfit) => self.set_aside.push(SetAside { + touched: mark.touched, + misfit, + }), + } + rows.push(row_key); + } + for mut draft in std::mem::take(&mut self.drafts) { + match layout.row(&draft.values) { + Ok(values) => { + draft.values = values; + self.drafts.push(draft); + } + Err(misfit) => { + visual.push(VisualChange::RemoveDraft { + draft_id: draft.draft_id, + }); + self.set_aside.push(SetAside { + touched: draft.touched, + misfit, + }); + } + } + } + if let Some(save) = self.saving.as_mut() { + save.written = std::mem::take(&mut save.written) + .into_iter() + .filter_map(|((row_key, col), value)| { + layout.key(&row_key).ok()?; + Some(((row_key, layout.position(col)?), value)) + }) + .collect(); + } + let undo: Option> = self.undo.iter().map(|op| op.relayout(layout)).collect(); + let redo: Option> = self.redo.iter().map(|op| op.relayout(layout)).collect(); + match (self.set_aside.len() == set_aside, undo, redo) { + (true, Some(undo), Some(redo)) => { + self.undo = undo; + self.redo = redo; + } + // A step back would bring a change to a place it no longer has. + _ => { + self.undo.clear(); + self.redo.clear(); + } + } + dedup(&mut rows); + self.delta(rows, visual) + } + + /// Why the changes set aside cannot be saved, the key first since + /// it strands every edited row. + fn set_aside_rejection(&self) -> Option { + if self.set_aside.is_empty() { + return None; + } + if self.set_aside.iter().any(|change| change.misfit == Misfit::Key) { + return Some(SaveRejection::KeyChanged); + } + let mut columns: Vec = Vec::new(); + for change in &self.set_aside { + if let Misfit::Columns(names) = &change.misfit { + for name in names { + if !columns.contains(name) { + columns.push(name.clone()); + } + } + } + } + Some(SaveRejection::ColumnsChanged(columns)) } pub(crate) fn pending_state(&self) -> PendingState { @@ -636,6 +751,9 @@ impl TabChangeTracker { if self.saving.is_some() { return Err(SaveRejection::AlreadySaving); } + if let Some(rejection) = self.set_aside_rejection() { + return Err(rejection); + } if self.pending_count() == 0 { return Ok(None); } @@ -735,6 +853,7 @@ impl TabChangeTracker { } !saved }); + self.set_aside.retain(|change| !snapshot.includes(change.touched)); self.drafts.retain(|draft| { let saved = snapshot.includes(draft.touched); if saved { @@ -1571,7 +1690,7 @@ mod tests { fn saving_on(columns: Vec) -> TabChangeTracker { let mut t = TabChangeTracker::default(); - t.set_target(SaveTarget { + let _ = t.set_target(SaveTarget { table: tablepro_core::meta::TableRef::unqualified("t"), columns, }); @@ -1653,6 +1772,155 @@ mod tests { assert_eq!(t.pending_count(), 0, "a return to what the save wrote stayed pending"); } + fn retarget(t: &mut TabChangeTracker, columns: &[ColumnInfo]) { + let _ = t.set_target(SaveTarget { + table: tablepro_core::meta::TableRef::unqualified("t"), + columns: columns.to_vec(), + }); + } + + fn rejection(t: &mut TabChangeTracker) -> String { + match t.begin_save() { + Err(rejection) => rejection.message(), + Ok(batch) => panic!("the save went ahead: {batch:?}"), + } + } + + #[test] + fn an_edit_follows_its_column_when_another_is_dropped() { + let before = vec![pk_col("id"), data_col("nickname"), data_col("name"), data_col("email")]; + let after = vec![pk_col("id"), data_col("name"), data_col("email")]; + let mut t = saving_on(before); + t.track_cell_edit(rk(&[Value::Int(1)]), 2, text("Ada"), text("Grace")); + + retarget(&mut t, &after); + + assert_eq!(assignments(&t, &after), vec![("name".to_owned(), text("Grace"))]); + assert_eq!(pending_at(&t, 1, 1), text("Grace")); + assert_eq!(t.cell_state(&rk(&[Value::Int(1)]), 2), CellState::Clean); + } + + #[test] + fn a_draft_follows_its_columns_when_one_is_added_and_one_dropped() { + let before = vec![pk_col("id"), data_col("nickname"), data_col("name")]; + let after = vec![pk_col("id"), data_col("name"), data_col("email")]; + let mut t = saving_on(before); + let draft_id = insert(&mut t, vec![Value::Null, Value::Null, text("Ada")]); + + retarget(&mut t, &after); + + assert_eq!( + t.draft_values(draft_id), + Some(&[Value::Null, text("Ada"), Value::Null][..]) + ); + } + + #[test] + fn an_edit_to_a_dropped_column_refuses_the_save() { + let before = vec![pk_col("id"), data_col("nickname"), data_col("name")]; + let after = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(before); + t.track_cell_edit(rk(&[Value::Int(1)]), 1, text("ada"), text("countess")); + edit(&mut t, 2, "Grace", "Hopper"); + + retarget(&mut t, &after); + + assert!(rejection(&mut t).contains("nickname")); + assert_eq!(t.pending_count(), 2, "an edit nothing can save was dropped"); + assert_eq!(t.pending_state(), PendingState::Unsaved); + } + + #[test] + fn a_draft_with_a_value_in_a_dropped_column_refuses_the_save() { + let before = vec![pk_col("id"), data_col("nickname"), data_col("name")]; + let after = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(before); + let _ = insert(&mut t, vec![Value::Null, text("ada"), text("Ada")]); + + retarget(&mut t, &after); + + assert!(rejection(&mut t).contains("nickname")); + assert_eq!(t.pending_count(), 1); + } + + #[test] + fn an_edit_that_no_longer_fits_a_retyped_column_refuses_the_save() { + let before = vec![pk_col("id"), test_column("score", "text")]; + let after = vec![pk_col("id"), test_column("score", "integer")]; + let mut t = saving_on(before); + t.track_cell_edit(rk(&[Value::Int(1)]), 1, text("1"), text("high")); + + retarget(&mut t, &after); + + assert!(rejection(&mut t).contains("score")); + } + + #[test] + fn an_edit_that_still_fits_a_retyped_column_saves() { + let before = vec![pk_col("id"), test_column("score", "integer")]; + let after = vec![pk_col("id"), test_column("score", "bigint")]; + let mut t = saving_on(before); + t.track_cell_edit(rk(&[Value::Int(1)]), 1, Value::Int(1), Value::Int(2)); + + retarget(&mut t, &after); + + assert_eq!(assignments(&t, &after), vec![("score".to_owned(), Value::Int(2))]); + assert!(t.begin_save().is_ok_and(|batch| batch.is_some())); + } + + #[test] + fn a_new_primary_key_refuses_edits_to_rows_named_by_the_old_one() { + let before = vec![pk_col("id"), test_column("legacy_id", "integer"), data_col("name")]; + let mut legacy_key = test_column("legacy_id", "integer"); + legacy_key.primary_key = true; + let mut plain_id = test_column("id", "integer"); + plain_id.nullable = false; + let after = vec![plain_id, legacy_key, data_col("name")]; + let mut t = saving_on(before); + t.track_cell_edit(rk(&[Value::Int(1)]), 2, text("Ada"), text("Grace")); + t.track_delete(rk(&[Value::Int(2)]), vec![Value::Int(2), Value::Int(9), text("Bob")]); + + retarget(&mut t, &after); + + assert_eq!(t.begin_save().err(), Some(SaveRejection::KeyChanged)); + assert_eq!(t.pending_count(), 2); + } + + #[test] + fn undo_after_a_column_is_dropped_steps_back_on_the_moved_column() { + let before = vec![pk_col("id"), data_col("nickname"), data_col("name")]; + let after = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(before); + t.track_cell_edit(rk(&[Value::Int(1)]), 2, text("Ada"), text("Grace")); + t.track_cell_edit(rk(&[Value::Int(1)]), 2, text("Ada"), text("Hopper")); + retarget(&mut t, &after); + + let _ = t.undo(); + + assert_eq!(assignments(&t, &after), vec![("name".to_owned(), text("Grace"))]); + let _ = t.redo(); + assert_eq!(assignments(&t, &after), vec![("name".to_owned(), text("Hopper"))]); + } + + #[test] + fn discard_drops_an_edit_the_columns_left_behind() { + let before = vec![pk_col("id"), data_col("nickname")]; + let after = vec![pk_col("id")]; + let mut t = saving_on(before); + t.track_cell_edit(rk(&[Value::Int(1)]), 1, text("ada"), text("countess")); + retarget(&mut t, &after); + + let _ = t.discard(); + + assert_eq!(t.pending_count(), 0); + assert!(matches!(t.begin_save(), Ok(None))); + } + + /// The cell as the grid draws it at `col`, over a fetched "Ada". + fn pending_at(t: &TabChangeTracker, id: i64, col: usize) -> Value { + t.cell_overlay(&rk(&[Value::Int(id)]), col, &text("Ada")).value + } + #[test] fn an_older_flash_timer_does_not_clear_a_newer_flash() { let mut t = TabChangeTracker::default(); diff --git a/linux/crates/app/src/services/column_relayout.rs b/linux/crates/app/src/services/column_relayout.rs new file mode 100644 index 0000000000..27dcc9777a --- /dev/null +++ b/linux/crates/app/src/services/column_relayout.rs @@ -0,0 +1,200 @@ +use tablepro_core::column::ColumnType; +use tablepro_core::edit::parse_literal_text; +use tablepro_core::export::value_text; +use tablepro_core::value::same_value; +use tablepro_core::{ColumnInfo, Value}; + +use super::change_tracker::RowKey; + +/// How the columns a tab's pending changes were made against line up +/// with the columns it read since. Columns are matched by name: once a +/// column is dropped or added, a position names another column. +#[derive(Debug)] +pub(crate) struct ColumnRelayout<'a> { + old: &'a [ColumnInfo], + new: &'a [ColumnInfo], + /// Each old column's position among the new ones. + positions: Vec>, + /// Each new column's position among the old ones. + sources: Vec>, + key_moved: bool, +} + +/// Why a pending change has no place under the new columns. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Misfit { + /// These columns are gone, or no longer take the value. + Columns(Vec), + /// The primary key changed, so a row key read under the old one + /// may name another row, or none. + Key, +} + +impl<'a> ColumnRelayout<'a> { + pub(crate) fn new(old: &'a [ColumnInfo], new: &'a [ColumnInfo]) -> Self { + let position = |name: &str, columns: &[ColumnInfo]| columns.iter().position(|column| column.name == name); + Self { + old, + new, + positions: old.iter().map(|column| position(&column.name, new)).collect(), + sources: new.iter().map(|column| position(&column.name, old)).collect(), + key_moved: key_names(old) != key_names(new), + } + } + + pub(crate) fn position(&self, col: usize) -> Option { + self.positions.get(col).copied().flatten() + } + + /// Where a value put in old column `col` belongs now. + pub(crate) fn cell(&self, col: usize, value: &Value) -> Result { + let misfit = || { + Misfit::Columns( + self.old + .get(col) + .map(|column| column.name.clone()) + .into_iter() + .collect(), + ) + }; + let (Some(old), Some(moved)) = (self.old.get(col), self.position(col)) else { + return Err(misfit()); + }; + match fits(value, &old.column_type, &self.new[moved].column_type) { + true => Ok(moved), + false => Err(misfit()), + } + } + + /// Whether a row key read under the old primary key names the same + /// row under the new one. + pub(crate) fn key(&self, key: &RowKey) -> Result<(), Misfit> { + let RowKey::Persisted(parts) = key else { + return Ok(()); + }; + if self.key_moved { + return Err(Misfit::Key); + } + let old = self.old.iter().filter(|column| column.primary_key); + let new = self.new.iter().filter(|column| column.primary_key); + match parts + .iter() + .zip(old.zip(new)) + .all(|(part, (old, new))| fits(part.value(), &old.column_type, &new.column_type)) + { + true => Ok(()), + false => Err(Misfit::Key), + } + } + + /// A new row's values under the new columns. A value the user left + /// in a column that is gone, or that no longer takes it, has no + /// place; an empty cell there is dropped. + pub(crate) fn row(&self, values: &[Value]) -> Result, Misfit> { + let mut lost = Vec::new(); + for (col, value) in values.iter().enumerate() { + if matches!(value, Value::Null) { + continue; + } + if let Err(Misfit::Columns(columns)) = self.cell(col, value) { + lost.extend(columns); + } + } + match lost.is_empty() { + true => Ok(self.fetched(values)), + false => Err(Misfit::Columns(lost)), + } + } + + /// A row as read under the old columns, under the new ones. A new + /// column reads as NULL. + pub(crate) fn fetched(&self, values: &[Value]) -> Vec { + self.sources + .iter() + .map(|source| source.and_then(|col| values.get(col)).cloned().unwrap_or(Value::Null)) + .collect() + } +} + +fn key_names(columns: &[ColumnInfo]) -> Vec<&str> { + columns + .iter() + .filter(|column| column.primary_key) + .map(|column| column.name.as_str()) + .collect() +} + +/// Whether a value made for a column of type `old` is still the value +/// a column of type `new` would take from the same text. NULL is left +/// to the server, which knows whether the column takes it. +fn fits(value: &Value, old: &ColumnType, new: &ColumnType) -> bool { + if old == new || matches!(value, Value::Null) { + return true; + } + value_text(value, old) + .and_then(|text| parse_literal_text(&text, new).ok()) + .is_some_and(|parsed| same_value(&parsed, value)) +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnKind, IntegerKind, IntegerWidth, TextLength}; + + use super::*; + + fn column(name: &str, kind: ColumnKind, primary_key: bool) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text(name); + column_type.kind = kind; + ColumnInfo { + name: name.to_owned(), + column_type, + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn integer(width: IntegerWidth) -> ColumnKind { + ColumnKind::Integer(IntegerKind { width, signed: true }) + } + + fn text(length: TextLength) -> ColumnKind { + ColumnKind::Text { length } + } + + #[test] + fn a_wider_type_takes_the_value_and_a_narrower_or_other_one_does_not() { + let int = column("n", integer(IntegerWidth::W32), false); + let wide = ColumnType { + kind: integer(IntegerWidth::W64), + ..int.column_type.clone() + }; + let narrow = ColumnType { + kind: integer(IntegerWidth::W8), + ..int.column_type.clone() + }; + let words = ColumnType { + kind: text(TextLength::Unbounded), + ..int.column_type.clone() + }; + + assert!(fits(&Value::Int(300), &int.column_type, &wide)); + assert!(!fits(&Value::Int(300), &int.column_type, &narrow)); + assert!(!fits(&Value::Int(3), &int.column_type, &words), "a number became text"); + assert!(fits(&Value::Null, &int.column_type, &words)); + } + + #[test] + fn a_key_column_left_out_of_the_key_moves_every_row_key() { + let old = [column("id", integer(IntegerWidth::W32), true)]; + let new = [column("id", integer(IntegerWidth::W32), false)]; + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + + assert_eq!(ColumnRelayout::new(&old, &new).key(&key), Err(Misfit::Key)); + assert_eq!(ColumnRelayout::new(&old, &old).key(&key), Ok(())); + assert_eq!(ColumnRelayout::new(&old, &new).key(&RowKey::Draft(1)), Ok(())); + } +} diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index f71f0e2d78..757bb5afca 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -2,6 +2,7 @@ pub mod active_session; pub mod cell_overlay; pub mod change_tracker; pub mod change_tracker_registry; +pub mod column_relayout; pub mod from_session; pub mod history_availability; pub mod history_service; diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs index 084ee4dd6c..14d73edd59 100644 --- a/linux/crates/app/src/services/tab_tracker_handle.rs +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -82,8 +82,9 @@ impl TabTrackerHandle { /// The table and columns the tab read, which every save is planned /// against. - pub(crate) fn set_target(&self, target: SaveTarget) { - self.0.borrow_mut().set_target(target); + #[must_use = "the grid shows stale rows until the delta is applied"] + pub(crate) fn set_target(&self, target: SaveTarget) -> TrackerDelta { + self.0.borrow_mut().set_target(target) } pub(crate) fn pending_state(&self) -> PendingState { @@ -135,7 +136,7 @@ mod tests { /// A tracker on a two-column table, with one edit pending. fn edited() -> TabTrackerHandle { let tracker = TabTrackerHandle::default(); - tracker.set_target(SaveTarget { + let _ = tracker.set_target(SaveTarget { table: TableRef::unqualified("t"), columns: vec![integer("id", true), integer("n", false)], }); diff --git a/linux/crates/app/src/services/undo_op.rs b/linux/crates/app/src/services/undo_op.rs index da5e6ad509..e8e65cda0a 100644 --- a/linux/crates/app/src/services/undo_op.rs +++ b/linux/crates/app/src/services/undo_op.rs @@ -1,6 +1,7 @@ use tablepro_core::Value; use super::change_tracker::{CellEdit, DeleteMark, DraftRow, RowKey}; +use super::column_relayout::ColumnRelayout; use super::touch::Touch; /// One step of a tab's undo history, holding the pending state on both @@ -36,6 +37,67 @@ pub enum UndoOp { } impl UndoOp { + /// The step under the columns `layout` moves to, or `None` when a + /// value in it has no place there. + pub(crate) fn relayout(&self, layout: &ColumnRelayout<'_>) -> Option { + let delete_mark = |mark: &DeleteMark| DeleteMark { + original_values: layout.fetched(&mark.original_values), + touched: mark.touched, + }; + match self { + UndoOp::CellEdit { + row_key, + col, + before, + after, + } => { + layout.key(row_key).ok()?; + let mut moved = layout.position(*col)?; + for edit in [before, after].into_iter().flatten() { + moved = layout.cell(*col, &edit.pending).ok()?; + } + Some(UndoOp::CellEdit { + row_key: row_key.clone(), + col: moved, + before: before.clone(), + after: after.clone(), + }) + } + UndoOp::DraftCell { + draft_id, + col, + before, + after, + before_touch, + after_touch, + } => { + layout.cell(*col, before).ok()?; + Some(UndoOp::DraftCell { + draft_id: *draft_id, + col: layout.cell(*col, after).ok()?, + before: before.clone(), + after: after.clone(), + before_touch: *before_touch, + after_touch: *after_touch, + }) + } + UndoOp::Insert { draft } => Some(UndoOp::Insert { + draft: DraftRow { + values: layout.row(&draft.values).ok()?, + ..draft.clone() + }, + }), + UndoOp::Delete { row_key, before, after } => { + layout.key(row_key).ok()?; + Some(UndoOp::Delete { + row_key: row_key.clone(), + before: before.as_ref().map(delete_mark), + after: delete_mark(after), + }) + } + } + } + pub fn is_about_draft(&self, draft_id: u64) -> bool { match self { UndoOp::Insert { draft } => draft.draft_id == draft_id, diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index f253e5d014..1196cd2a61 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -757,6 +757,9 @@ impl BrowseModel { /// Checked again here because a keyboard shortcut or a late event /// can reach a locked cell. fn refuses_edit(&self, row: &RowObject, column: usize) -> bool { + if self.grid_is_stale() { + return true; + } let Some(info) = self.current_columns.get(column) else { return true; }; @@ -1135,6 +1138,30 @@ impl BrowseModel { self.current_column_view.is_some() && self.rendered_columns == self.current_columns } + /// A row event from a grid built for other columns names its cells + /// by positions that now hold other columns. + fn grid_is_stale(&self) -> bool { + let stale = !self.column_view_matches_current_columns(); + if stale { + tracing::debug!("dropped a row event from a grid built for other columns"); + } + stale + } + + /// The grid was built for columns the table no longer has, so it + /// goes before a click or a key in it lands on a moved column. + fn drop_grid(&mut self) { + if self.current_column_view.take().is_none() { + return; + } + clear_box(&self.grid_holder); + self.current_selection = None; + self.rendered_columns.clear(); + self.row_index = GridRowIndex::default(); + update_selection_chrome(&self.selection_label, 0); + self.show_loading(); + } + /// New rows under the same columns. The drafts go back on top so /// they survive the swap. fn refresh_grid_data(&mut self, result: &QueryResult, store: >k::gio::ListStore) { @@ -1339,12 +1366,14 @@ impl BrowseModel { if self.current_columns != columns { self.current_result = None; self.shown_view = None; + self.drop_grid(); } self.current_columns = columns.clone(); - self.tracker.set_target(SaveTarget { + let delta = self.tracker.set_target(SaveTarget { table: self.table_ref(), columns: columns.clone(), }); + self.apply_tracker_delta(&delta, fx); if let Some(result) = self.current_result.as_mut() && result.columns.is_empty() { @@ -1491,7 +1520,7 @@ impl BrowseModel { fx.output(BrowseTabOutput::StateChanged); } BrowseTabInput::DuplicateRow { row } => { - if self.current_columns.is_empty() { + if self.current_columns.is_empty() || self.grid_is_stale() { return; } // With its pending edits, since the copy is of the row the @@ -1590,6 +1619,9 @@ impl BrowseModel { })); } BrowseTabInput::GridDeleteRowAt { row } => { + if self.grid_is_stale() { + return; + } let (Some(row_key), Some(cells)) = (row.key(), row.cells()) else { return; }; @@ -1601,7 +1633,11 @@ impl BrowseModel { fx, ); } - BrowseTabInput::GridCopyRowAsInsert { row } => self.copy_row_as_insert(&row, fx), + BrowseTabInput::GridCopyRowAsInsert { row } => { + if !self.grid_is_stale() { + self.copy_row_as_insert(&row, fx); + } + } BrowseTabInput::GridCopyToClipboard(text) => { fx.output(BrowseTabOutput::CopyToClipboard(text)); } diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index e8cd0b7364..2857592b22 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -1210,3 +1210,155 @@ fn copy_row_as_insert_on_a_draft_leaves_what_the_server_fills_out() { toasts.borrow() ); } + +fn people(names: &[&str]) -> Vec { + std::iter::once(column("id", true)) + .chain(names.iter().map(|name| column(name, false))) + .collect() +} + +/// One row, id 0, each cell holding its column's name. +fn people_page(columns: &[ColumnInfo]) -> QueryResult { + QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![ + std::iter::once(Value::Int(0)) + .chain(columns[1..].iter().map(|column| Value::Text(column.name.clone()))) + .collect(), + ], + ) +} + +/// A table whose `nickname` another client drops before the tab reads +/// its columns again. `after` is what the page under the new columns +/// answers. +fn losing_nickname(after: FakeReply) -> ConnectionScript { + let before = people(&["nickname", "name", "email"]); + let mut script = serving_table(); + script + .fetch_columns + .once(FakeReply::ok(before.clone())) + .always(FakeReply::ok(people(&["name", "email"]))); + script + .query_bound + .when_sql_contains("nickname", FakeReply::ok(people_page(&before))) + .always(after); + script +} + +fn without_nickname() -> FakeReply { + FakeReply::ok(people_page(&people(&["name", "email"]))) +} + +/// Each assignment of the save, by the name of the column it writes. +fn saved_cells(tracker: &TabTrackerHandle) -> Vec<(String, Option)> { + let batch = tracker.begin_save().expect("a save").expect("changes to save"); + batch + .changes + .changes + .changes + .iter() + .flat_map(|change| match change { + tablepro_core::save::RowChange::Update(update) => update.assignments.clone(), + other => panic!("only a cell was edited: {other:?}"), + }) + .map(|(col, value)| { + ( + batch.columns[col].name.clone(), + tablepro_core::export::value_to_text(&value), + ) + }) + .collect() +} + +fn edit_first_row(harness: &Harness, column: usize, input: &str) { + harness.tab().send(BrowseTabInput::GridCellEdited { + row: harness.grid_row(0), + column, + input: input.to_owned(), + }); + assert_eq!(harness.tracker.pending_count(), 1); +} + +fn headers_are(tab: &BrowseTab, expected: &[&str]) -> bool { + header_titles(tab) == expected +} + +#[gtk4::test] +fn refresh_after_a_column_is_dropped_keeps_an_edit_on_its_column() { + let harness = Harness::open(losing_nickname(without_nickname()), []); + wait_until(WAIT, || { + headers_are(harness.tab(), &["id", "nickname", "name", "email"]) + }) + .expect("the first grid"); + edit_first_row(&harness, 2, "Grace"); + + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || headers_are(harness.tab(), &["id", "name", "email"])).expect("the grid under the new columns"); + + assert_eq!( + saved_cells(&harness.tracker), + vec![("name".to_owned(), Some("Grace".to_owned()))] + ); +} + +#[gtk4::test] +fn a_structure_save_that_drops_the_edited_column_refuses_the_save() { + let harness = Harness::open(losing_nickname(without_nickname()), []); + wait_until(WAIT, || { + headers_are(harness.tab(), &["id", "nickname", "name", "email"]) + }) + .expect("the first grid"); + edit_first_row(&harness, 1, "countess"); + + harness + .session + .invalidate_table(&tablepro_core::TableRef::unqualified("users")); + harness.tab().send(BrowseTabInput::SchemaChanged); + wait_until(WAIT, || headers_are(harness.tab(), &["id", "name", "email"])).expect("the grid under the new columns"); + + let refused = harness + .tracker + .begin_save() + .map(|batch| batch.map(|batch| batch.changes.changes)); + assert!( + refused + .as_ref() + .is_err_and(|rejection| rejection.message().contains("nickname")), + "{refused:?}" + ); + assert_eq!( + harness.tracker.pending_count(), + 1, + "the edit was dropped without a word" + ); +} + +#[gtk4::test] +fn a_grid_built_for_columns_the_table_lost_takes_no_edits() { + let harness = Harness::open(losing_nickname(FakeReply::UntilStopped), []); + wait_until(WAIT, || { + headers_are(harness.tab(), &["id", "nickname", "name", "email"]) + }) + .expect("the first grid"); + let row = harness.grid_row(0); + + harness + .session + .invalidate_table(&tablepro_core::TableRef::unqualified("users")); + harness.tab().send(BrowseTabInput::SchemaChanged); + wait_until(WAIT, || harness.tab().columns() == people(&["name", "email"])).expect("the new columns"); + // Column 2 was name in the grid the event came from, and is email now. + harness.tab().send(BrowseTabInput::GridCellEdited { + row, + column: 2, + input: "Grace".to_owned(), + }); + + assert_eq!( + harness.tracker.pending_count(), + 0, + "an edit in the old grid landed on another column" + ); + assert_eq!(harness.visible_page().as_deref(), Some("loading")); +} diff --git a/linux/crates/app/src/workspace/save_rejection.rs b/linux/crates/app/src/workspace/save_rejection.rs index 3c03c79620..4602d55940 100644 --- a/linux/crates/app/src/workspace/save_rejection.rs +++ b/linux/crates/app/src/workspace/save_rejection.rs @@ -1,6 +1,7 @@ use tablepro_core::dml::BuildSqlError; use tablepro_session::OperationError; +use super::DirtyTab; use crate::services::pending_structure::SaveBlocker; /// Why a save did not start. Nothing was sent. @@ -16,6 +17,12 @@ pub enum SaveRejection { ColumnsNotRead, /// The row changes cannot be written as SQL. Build(BuildSqlError), + /// Edits made before the table's columns changed are for these + /// columns, which are gone or no longer take them. + ColumnsChanged(Vec), + /// Edits made before the table's primary key changed name their + /// rows by the old one. + KeyChanged, /// The structure cannot be saved as it stands. Blocked(SaveBlocker), NoConnection, @@ -34,6 +41,16 @@ impl SaveRejection { crate::i18n::gettext("The table's columns have not been read yet. Try again in a moment.") } Self::Build(error) => crate::ui::error_text::operation_error_message(&OperationError::Build(error.clone())), + Self::ColumnsChanged(columns) => { + let quoted: Vec = columns.iter().map(|name| DirtyTab::quote(name)).collect(); + crate::i18n::gettext_f( + "The table changed after these edits were made, and the edits to {columns} no longer fit it. Nothing was saved. Discard the changes and make them again.", + &[("columns", "ed.join(", "))], + ) + } + Self::KeyChanged => crate::i18n::gettext( + "The table's primary key changed after these edits were made, so the rows they change can no longer be found. Nothing was saved. Discard the changes and make them again.", + ), Self::Blocked(blocker) => crate::ui::error_text::save_blocker_text(blocker), Self::NoConnection => crate::i18n::gettext("No active connection."), } @@ -60,12 +77,25 @@ mod tests { SaveRejection::ColumnsNotRead, SaveRejection::Blocked(SaveBlocker::Draft(DraftProblem::NoColumns)), SaveRejection::NoConnection, + SaveRejection::ColumnsChanged(vec!["nickname".to_owned()]), + SaveRejection::KeyChanged, ]; assert!(told.iter().all(SaveRejection::needs_alert)); assert!(!SaveRejection::AlreadySaving.needs_alert()); } + #[test] + fn edits_the_columns_left_behind_name_their_columns() { + let rejection = SaveRejection::ColumnsChanged(vec!["nickname".to_owned(), "age".to_owned()]); + + assert!( + rejection.message().contains("“nickname”, “age”"), + "{}", + rejection.message() + ); + } + #[test] fn a_blocked_structure_says_what_to_fix() { let rejection = SaveRejection::Blocked(SaveBlocker::Draft(DraftProblem::NoColumns)); diff --git a/linux/crates/app/src/workspace/test_workspace.rs b/linux/crates/app/src/workspace/test_workspace.rs index 84b7ef59e0..a64d097d4f 100644 --- a/linux/crates/app/src/workspace/test_workspace.rs +++ b/linux/crates/app/src/workspace/test_workspace.rs @@ -81,7 +81,7 @@ impl Workspace { let rows = Rc::new(ChangeTrackerRegistry::default()); let tab = Uuid::new_v4(); let tracker = rows.register(tab); - tracker.set_target(SaveTarget { + let _ = tracker.set_target(SaveTarget { table: TableRef::unqualified("t"), columns: vec![integer("id", true), integer("n", false)], }); diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index b9edcf0774..27b2ed9ca2 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -22,6 +22,7 @@ crates/app/src/services/active_session.rs crates/app/src/services/cell_overlay.rs crates/app/src/services/change_tracker.rs crates/app/src/services/change_tracker_registry.rs +crates/app/src/services/column_relayout.rs crates/app/src/services/from_session.rs crates/app/src/services/history_availability.rs crates/app/src/services/history_service.rs From 996442238f7428dd8e24aba5d6a25ce78f0513d6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 03:14:25 +0700 Subject: [PATCH 174/178] fix(linux-grid): edit a cell from its whole value, not the display text --- linux/CHANGELOG.md | 1 + linux/crates/app/src/ui/grid/cell_editor.rs | 7 +- linux/crates/app/src/ui/grid/cell_popovers.rs | 31 +++++--- .../crates/app/src/ui/grid/column_drawing.rs | 10 +-- .../crates/app/src/ui/grid/column_factory.rs | 79 +++++++++++++++++++ linux/crates/app/src/ui/grid/display_text.rs | 52 +++++++++--- linux/crates/app/src/ui/grid/grid_cell.rs | 27 ++++++- linux/crates/app/src/ui/grid/inline_edit.rs | 45 +++++++++-- 8 files changed, 214 insertions(+), 38 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 9f8819eb08..71337c5a22 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -56,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Undo after editing a cell twice restored the fetched value instead of the first edit. - An edit made while a save ran was dropped when it set the cell back to the value it was read with. - Refresh or a structure change while edits were pending could save an edit into another column or row. +- Editing a long text cell saved only its first 10,000 characters, and a blob in a text column saved its size label. - A save that deleted a row and added a new one with the same unique value failed. - The window title showed no unsaved-changes bullet for pending structure changes. - `real` values showed extra digits in the grid, copies and exports, such as 0.10000000149011612 for 0.1. diff --git a/linux/crates/app/src/ui/grid/cell_editor.rs b/linux/crates/app/src/ui/grid/cell_editor.rs index 16641f8fba..50a3b95b6a 100644 --- a/linux/crates/app/src/ui/grid/cell_editor.rs +++ b/linux/crates/app/src/ui/grid/cell_editor.rs @@ -159,10 +159,13 @@ impl CellEditor { .unwrap_or(false) } - pub fn start_editing(&self) { + /// `text` is the whole value, never what the label drew: that has + /// `` for a NULL. The label takes it too, so Escape puts back + /// what the edit started from and sends nothing. + pub fn start_editing(&self, text: &str) { let stack = self.stack_widget(); let entry = self.entry_widget(); - entry.set_text(&self.label_widget().text()); + self.set_text(text); stack.set_visible_child_name("edit"); entry.grab_focus(); // The first keystroke replaces the value, as in a Files rename. diff --git a/linux/crates/app/src/ui/grid/cell_popovers.rs b/linux/crates/app/src/ui/grid/cell_popovers.rs index a87a4065d9..e843aceca1 100644 --- a/linux/crates/app/src/ui/grid/cell_popovers.rs +++ b/linux/crates/app/src/ui/grid/cell_popovers.rs @@ -3,12 +3,11 @@ use gtk4::prelude::*; use gtk4::{self as gtk, glib}; use sourceview5::prelude::*; +use super::display_text::truncate_for_display; use super::{GridCell, GridMsg, GridSink}; fn current_text(cell: &GridCell) -> String { - cell.editor() - .map(|editor| editor.text().to_string()) - .unwrap_or_default() + cell.edit_text().unwrap_or_default() } /// Parent the popover to the cell and keep it there until it closes. @@ -32,7 +31,7 @@ fn present(cell: &GridCell, popover: >k::Popover) { fn commit(cell: &GridCell, popover: >k::Popover, input: String, sink: &GridSink) { // Drawn now rather than after the tracker's round trip. if let Some(editor) = cell.editor() { - editor.set_text(&input); + editor.set_text(&truncate_for_display(&input)); } let row = cell.row(); popover.popdown(); @@ -102,8 +101,6 @@ pub(super) fn show_spin_button_popover(cell: &GridCell, sink: &GridSink, is_floa spin.grab_focus(); } -/// Saves through a button, since Return inserts a newline in a -/// multi-line buffer. pub(super) fn show_json_popover(cell: &GridCell, sink: &GridSink) { let buffer = sourceview5::Buffer::new(None); if let Some(lang) = sourceview5::LanguageManager::default().language("json") { @@ -117,9 +114,25 @@ pub(super) fn show_json_popover(cell: &GridCell, sink: &GridSink) { view.set_auto_indent(true); view.set_tab_width(2); view.set_indent_width(2); + show_buffer_popover(cell, sink, &view); +} + +/// For a text the one-line editor cannot lay out without stalling. +pub(super) fn show_long_text_popover(cell: &GridCell, sink: &GridSink, text: &str) { + let buffer = sourceview5::Buffer::new(None); + buffer.set_text(text); + let view = sourceview5::View::with_buffer(&buffer); + view.set_wrap_mode(gtk::WrapMode::WordChar); + show_buffer_popover(cell, sink, &view); +} + +/// Saves through a button, since Return inserts a newline in a +/// multi-line buffer. +fn show_buffer_popover(cell: &GridCell, sink: &GridSink, view: &sourceview5::View) { + let buffer = view.buffer(); let scrolled = gtk::ScrolledWindow::builder() - .child(&view) + .child(view) .min_content_width(420) .min_content_height(280) .has_frame(true) @@ -169,8 +182,8 @@ pub(super) fn show_json_popover(cell: &GridCell, sink: &GridSink) { let start = buffer_for_commit.start_iter(); let end = buffer_for_commit.end_iter(); let text = buffer_for_commit.text(&start, &end, true).to_string(); - // The tab validates the JSON and refuses a malformed value with - // a toast. + // The tab parses the text for its column and refuses, with a + // toast, a value the column cannot take. commit(&cell, &popover, text, &sink); }); diff --git a/linux/crates/app/src/ui/grid/column_drawing.rs b/linux/crates/app/src/ui/grid/column_drawing.rs index 10665f06f9..d2227eeaae 100644 --- a/linux/crates/app/src/ui/grid/column_drawing.rs +++ b/linux/crates/app/src/ui/grid/column_drawing.rs @@ -3,7 +3,7 @@ use gtk4::prelude::*; use tablepro_core::{ColumnInfo, Value}; -use super::display_text::{auto_filled_sentinel, editable_null_sentinel, value_to_display_text, value_to_edit_text}; +use super::display_text::{auto_filled_sentinel, editable_null_sentinel, value_to_display_text}; use super::{CellEditor, CellLock, GridCell, GridRowId, RowObject, TabGridContext, cell_editability}; const PENDING_CSS_CLASSES: &[&str] = &[ @@ -50,12 +50,8 @@ fn draw_cell(grid_cell: &GridCell, value: &Value, pending_classes: &[&'static st let is_null = matches!(value, Value::Null); let text = if is_null && auto_filled { auto_filled_sentinel() - } else if editable { - if is_null { - editable_null_sentinel() - } else { - value_to_edit_text(value) - } + } else if is_null && editable { + editable_null_sentinel() } else { value_to_display_text(value) }; diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index 67386b3378..659e8c861f 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -246,6 +246,7 @@ fn setup_editable_cell(grid_cell: &GridCell, sink: GridSink, editor_kind: CellEd editor.set_margin_start(8); editor.set_margin_end(8); grid_cell.set_content(&editor); + grid_cell.set_sink(sink.clone()); install_edit_commit_handler(grid_cell, sink.clone()); install_edit_triggers(grid_cell, sink, editor_kind); } @@ -790,6 +791,84 @@ mod tests { assert_eq!(edited_rows(&grid.take_messages()), vec![]); } + /// Past the length the grid cuts a cell's text at. + fn long_text() -> String { + format!("tpyo{}", "x".repeat(60_000)) + } + + /// The text of whichever editor the cell opened: its entry, or the + /// popover it opened instead. + fn opened_text(cell: &GridCell) -> Option { + let editor = cell.editor()?; + if editor.is_editing() { + return Some(editor.entry().text().to_string()); + } + let view = crate::test_support::first_descendant_of_type::(cell)?; + let buffer = view.buffer(); + Some(buffer.text(&buffer.start_iter(), &buffer.end_iter(), true).to_string()) + } + + #[gtk4::test] + fn editing_a_long_text_starts_from_the_whole_value() { + let grid = name_rows(Value::Text(long_text())); + let cell = grid.cell_of(1, 1); + + enter_edit_mode(&cell); + + let opened = opened_text(&cell).expect("an editor opened"); + assert_eq!(opened.len(), long_text().len(), "the editor opened on the cut text"); + assert_eq!(opened, long_text()); + } + + #[gtk4::test] + fn a_long_text_saved_from_its_editor_sends_the_whole_value() { + let grid = name_rows(Value::Text(long_text())); + let cell = grid.cell_of(1, 1); + enter_edit_mode(&cell); + let fixed = long_text().replacen("tpyo", "typo", 1); + let view = crate::test_support::first_descendant_of_type::(&cell).expect("a text popover"); + + view.buffer().set_text(&fixed); + descendants(&cell) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.has_css_class("suggested-action")) + .expect("the Save button") + .emit_clicked(); + + assert_eq!(edited_rows(&grid.take_messages()), vec![(Some(key(1)), 1, fixed)]); + assert!( + cell.editor().is_some_and(|editor| editor.text().len() < 20_000), + "the cell drew the whole value" + ); + } + + #[gtk4::test] + fn a_text_that_reads_as_the_null_sentinel_opens_as_itself() { + let grid = name_rows(Value::Text(editable_null_sentinel())); + let cell = grid.cell_of(1, 1); + + enter_edit_mode(&cell); + + assert_eq!(opened_text(&cell), Some(editable_null_sentinel())); + } + + #[gtk4::test] + fn a_blob_in_a_text_column_does_not_open_on_its_placeholder() { + let grid = name_rows(Value::Bytes(vec![1, 2, 3])); + let cell = grid.cell_of(1, 1); + + enter_edit_mode(&cell); + + assert_eq!(opened_text(&cell), None, "the editor opened on the size placeholder"); + assert!( + grid.take_messages() + .iter() + .any(|msg| matches!(msg, GridMsg::ShowToast(_))), + "the cell did not say why it would not open" + ); + } + fn width_binding(tasks: &tablepro_session::runtime::Tasks, root: &tempfile::TempDir) -> ColumnWidthBinding { ColumnWidthBinding { store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), tasks), diff --git a/linux/crates/app/src/ui/grid/display_text.rs b/linux/crates/app/src/ui/grid/display_text.rs index a8351fd9a3..e6883cd517 100644 --- a/linux/crates/app/src/ui/grid/display_text.rs +++ b/linux/crates/app/src/ui/grid/display_text.rs @@ -41,14 +41,24 @@ pub(super) fn value_to_display_text(value: &Value) -> String { } } -pub(super) fn value_to_edit_text(value: &Value) -> String { +/// The whole value as an editor starts from it, which committed +/// unchanged writes the value back. Never the display text, which is cut +/// short or stands in for a value. `None` for a value with no text form. +pub(super) fn value_to_edit_text(value: &Value) -> Option { match value { - Value::Null => String::new(), - other => value_to_display_text(other), + Value::Null => Some(String::new()), + Value::Bytes(_) | Value::Undecodable(_) => None, + other => tablepro_core::export::value_to_text(other), } } -fn truncate_for_display(s: &str) -> String { +/// Whether the cell shows only the start of `s`. Such a text is too long +/// for the one-line editor, which lays out all of it. +pub(super) fn is_cut_for_display(s: &str) -> bool { + s.len() >= DISPLAY_TEXT_BYTES_THRESHOLD && s.chars().nth(DISPLAY_TEXT_MAX_CHARS).is_some() +} + +pub(super) fn truncate_for_display(s: &str) -> String { if s.len() < DISPLAY_TEXT_BYTES_THRESHOLD { return s.to_string(); } @@ -72,7 +82,7 @@ fn truncate_for_display(s: &str) -> String { mod tests { use tablepro_core::Value; - use super::{truncate_for_display, value_to_display_text, value_to_edit_text}; + use super::{is_cut_for_display, truncate_for_display, value_to_display_text, value_to_edit_text}; #[test] fn display_text_primitive_variants() { @@ -146,22 +156,42 @@ mod tests { #[test] fn edit_text_distinguishes_null_from_text_null() { - assert_eq!(value_to_edit_text(&Value::Null), ""); - assert_eq!(value_to_edit_text(&Value::Text("NULL".into())), "NULL"); - assert_eq!(value_to_edit_text(&Value::Int(0)), "0"); + assert_eq!(value_to_edit_text(&Value::Null).as_deref(), Some("")); + assert_eq!(value_to_edit_text(&Value::Text("NULL".into())).as_deref(), Some("NULL")); + assert_eq!(value_to_edit_text(&Value::Int(0)).as_deref(), Some("0")); } #[test] fn edit_text_keeps_extended_variants_visible() { - assert_eq!(value_to_edit_text(&date_value(2026, 4, 26)), "2026-04-26"); + assert_eq!( + value_to_edit_text(&date_value(2026, 4, 26)).as_deref(), + Some("2026-04-26") + ); let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); assert_eq!( - value_to_edit_text(&Value::Uuid(id)), - "550e8400-e29b-41d4-a716-446655440000" + value_to_edit_text(&Value::Uuid(id)).as_deref(), + Some("550e8400-e29b-41d4-a716-446655440000") ); } + #[test] + fn edit_text_is_the_whole_value_however_long() { + let huge = "x".repeat(1_000_000); + + assert_eq!(value_to_edit_text(&Value::Text(huge.clone())), Some(huge.clone())); + assert!(is_cut_for_display(&huge)); + assert!(!is_cut_for_display(&"x".repeat(39_999))); + // 40,000 bytes, but only 10,000 characters, which the cell shows + // whole. + assert!(!is_cut_for_display(&"🦀".repeat(10_000))); + } + + #[test] + fn a_value_shown_by_a_stand_in_has_no_edit_text() { + assert_eq!(value_to_edit_text(&Value::Bytes(vec![0u8; 16])), None); + } + #[test] fn truncate_short_text_passes_through() { let s = "hello world"; diff --git a/linux/crates/app/src/ui/grid/grid_cell.rs b/linux/crates/app/src/ui/grid/grid_cell.rs index 4ee89af1d4..bbc7433be9 100644 --- a/linux/crates/app/src/ui/grid/grid_cell.rs +++ b/linux/crates/app/src/ui/grid/grid_cell.rs @@ -7,7 +7,8 @@ use gtk4::{self as gtk, glib}; use super::cell_binding::CellBinding; use super::column_drawing::ColumnDrawing; -use super::{CellEditor, CellLock, EditSnapshot, RowObject}; +use super::display_text::value_to_edit_text; +use super::{CellEditor, CellLock, EditSnapshot, GridSink, RowObject}; mod imp { use super::*; @@ -30,6 +31,8 @@ mod imp { pub preedit_active: Cell, pub lock: Cell>, pub drawing: OnceCell>, + /// Where an editor that opens in a popover sends its value. + pub sink: OnceCell, pub binding: RefCell>, /// The label cannot say what was drawn once an edit has cleared /// a NULL sentinel from it. @@ -140,6 +143,28 @@ impl GridCell { let _ = self.imp().drawing.set(drawing); } + pub(super) fn set_sink(&self, sink: GridSink) { + let _ = self.imp().sink.set(sink); + } + + pub(super) fn sink(&self) -> Option { + self.imp().sink.get().cloned() + } + + /// The whole value the cell draws, pending edit included, as an + /// editor starts from it. `None` with no row bound or no text form. + pub(super) fn edit_text(&self) -> Option { + let row = self + .imp() + .binding + .borrow() + .as_ref() + .map(|binding| binding.row().clone())?; + let drawing = self.imp().drawing.get()?; + let (value, _) = drawing.tab_ctx.bind_state(&row, self.column()); + value_to_edit_text(&value) + } + pub(super) fn bind(&self, row: &RowObject) { let lock = match self.imp().drawing.get() { Some(drawing) => drawing.lock(row.id()), diff --git a/linux/crates/app/src/ui/grid/inline_edit.rs b/linux/crates/app/src/ui/grid/inline_edit.rs index 50354dad67..a28c397187 100644 --- a/linux/crates/app/src/ui/grid/inline_edit.rs +++ b/linux/crates/app/src/ui/grid/inline_edit.rs @@ -4,11 +4,15 @@ use gtk4::prelude::*; use gtk4::{self as gtk, glib}; use super::cell_editor_kind::CellEditorKind; -use super::cell_popovers::{show_calendar_popover, show_json_popover, show_spin_button_popover}; -use super::display_text::editable_null_sentinel; +use super::cell_popovers::{ + show_calendar_popover, show_json_popover, show_long_text_popover, show_spin_button_popover, +}; +use super::display_text::is_cut_for_display; use super::{EditSnapshot, GridCell, GridMsg, GridSink}; -/// Clears the `` sentinel first, so the user does not type over it. +/// Opens on the whole value, never on the text the cell draws: that +/// is cut short for a long value, and saving it would cut the value. +/// A value too long for the one-line editor opens in a popover. pub(crate) fn enter_edit_mode(cell: &GridCell) { if cell.lock().is_some() { return; @@ -16,10 +20,30 @@ pub(crate) fn enter_edit_mode(cell: &GridCell) { let Some(editor) = cell.editor() else { return; }; - if editor.text().as_str() == editable_null_sentinel() { - editor.set_text(""); + let Some(text) = editable_text(cell) else { + return; + }; + if !is_cut_for_display(&text) { + editor.start_editing(&text); + return; + } + if let Some(sink) = cell.sink() { + show_long_text_popover(cell, &sink, &text); } - editor.start_editing(); +} + +/// The cell's whole value, or `None` after telling the user why it +/// cannot be typed over. +fn editable_text(cell: &GridCell) -> Option { + let text = cell.edit_text(); + if text.is_none() + && let Some(sink) = cell.sink() + { + sink(GridMsg::ShowToast(crate::i18n::gettext( + "This value has no text form to edit.", + ))); + } + text } /// `child_focus` on the window is what a real Tab press does. @@ -40,9 +64,14 @@ pub(super) fn install_edit_triggers(cell: &GridCell, sink: GridSink, editor_kind CellEditorKind::Json => Rc::new(move |cell| show_json_popover(cell, &sink)), }; let trigger = move |cell: &GridCell| { - if cell.lock().is_none() { - open(cell); + if cell.lock().is_some() { + return; + } + // The text editor checks for itself, since the menu opens it too. + if editor_kind != CellEditorKind::Text && editable_text(cell).is_none() { + return; } + open(cell); }; let trigger = Rc::new(trigger); From e42dcf3742417f3e7bcfdf1f234a7ffa6c068567 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 03:24:39 +0700 Subject: [PATCH 175/178] fix(linux): count a default the parser refused as an unsaved change --- .../app/src/services/pending_structure.rs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/linux/crates/app/src/services/pending_structure.rs b/linux/crates/app/src/services/pending_structure.rs index 773da6b9e3..ef5b5ccc33 100644 --- a/linux/crates/app/src/services/pending_structure.rs +++ b/linux/crates/app/src/services/pending_structure.rs @@ -35,18 +35,22 @@ impl PendingStructure { } } + /// A default the parser refused is still the user's edit: the draft + /// cannot hold it, so without counting it here a close would drop it + /// without asking. pub fn has_changes(&self) -> bool { - match self.original { + let drafted = match self.original { Some(_) => !self.changes.is_empty(), None => !self.draft.columns.is_empty(), - } + }; + drafted || !self.unparsed_defaults.is_empty() } /// The edits the action bar counts. A new table is one. pub fn change_count(&self) -> usize { match self.original { - Some(_) => self.changes.change_count(), - None => usize::from(!self.draft.columns.is_empty()), + Some(_) => self.changes.change_count() + self.unparsed_defaults.len(), + None => usize::from(!self.draft.columns.is_empty() || !self.unparsed_defaults.is_empty()), } } @@ -206,6 +210,16 @@ mod tests { assert_eq!(created().operation(), DdlOperation::CreateTable); } + #[test] + fn a_default_the_parser_refused_is_a_pending_change() { + let mut typed_default = edited(|_| {}); + typed_default.unparsed_defaults = vec!["total".to_owned()]; + + assert!(!edited(|_| {}).has_changes()); + assert!(typed_default.has_changes()); + assert_eq!(typed_default.change_count(), 1); + } + #[test] fn an_edit_counts_its_changes_and_follows_a_rename() { let original = TableDefinition { From 4ed2cb98f7428efcb616ae6777d4d6945cf34408 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 03:24:41 +0700 Subject: [PATCH 176/178] fix(linux): declare a SQL Server identity column NOT NULL --- linux/crates/drivers/mssql/src/ddl.rs | 29 ++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/linux/crates/drivers/mssql/src/ddl.rs b/linux/crates/drivers/mssql/src/ddl.rs index f26f4742d0..596b74a5af 100644 --- a/linux/crates/drivers/mssql/src/ddl.rs +++ b/linux/crates/drivers/mssql/src/ddl.rs @@ -317,7 +317,15 @@ impl MssqlDialect { if identity { parts.push("IDENTITY(1,1)".to_owned()); } - parts.push(if column.nullable { "NULL" } else { "NOT NULL" }.to_owned()); + // SQL Server refuses an identity on a nullable column (error 8147). + parts.push( + if column.nullable && !identity { + "NULL" + } else { + "NOT NULL" + } + .to_owned(), + ); // An identity column cannot carry a default. if !identity && let Some(default) = default_sql(self, &column.name, &column.default, &resolved.column_type)? { parts.push(format!("DEFAULT {}", default_clause(&column.default, &default))); @@ -564,6 +572,25 @@ mod tests { ); } + #[test] + fn an_identity_outside_the_key_is_not_null() { + let mut draft = TableDraft::new(TableRef::new(Some("dbo".to_owned()), "events")); + let mut id = typed("id", "uniqueidentifier"); + id.primary_key = true; + id.nullable = false; + let mut seq = typed("seq", "int"); + seq.auto_increment = true; + draft.columns = vec![id, seq]; + + assert_eq!( + sql(dialect().plan_create_table(&draft)), + [ + "CREATE TABLE [dbo].[events] (\n [id] uniqueidentifier NOT NULL,\n [seq] int IDENTITY(1,1) NOT NULL,\n \ + PRIMARY KEY ([id])\n)" + ] + ); + } + #[test] fn plan_create_table_composite_key_index_and_foreign_key() { let mut draft = TableDraft::new(TableRef::new(Some("dbo".to_owned()), "lines")); From fb5053fae5fce039963d0c7917e549943272cfc5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 03:58:13 +0700 Subject: [PATCH 177/178] ci(linux): scope a shellcheck exception to the GTK-thread I/O check --- .github/workflows/build-linux.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 6bfef54103..fafc058e7b 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -112,6 +112,8 @@ jobs: # _blocking is handed to spawn_blocking_task on the same line, # which is what this check can see. Tests may do either. run: | + # The $0 in the awk program is awk's record, not a shell variable. + # shellcheck disable=SC2016 found=$(find crates/app/src/ui/app crates/app/src/ui/editor -name '*.rs' ! -name tests.rs -print0 \ | xargs -0 awk '/^mod tests \{/ { nextfile } /std::fs::/ || (/_blocking\(/ && !/spawn_blocking_task\(/) { print FILENAME ":" FNR ": " $0 }') if [ -n "$found" ]; then From 2c2ce19dcac98c0c825beca099d328bf188f2935 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 03:58:15 +0700 Subject: [PATCH 178/178] test(linux): give the ClickHouse fixture three minutes to start --- linux/crates/test-fixtures/src/clickhouse_fixture.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/linux/crates/test-fixtures/src/clickhouse_fixture.rs b/linux/crates/test-fixtures/src/clickhouse_fixture.rs index eb8f49b41b..c512dc9485 100644 --- a/linux/crates/test-fixtures/src/clickhouse_fixture.rs +++ b/linux/crates/test-fixtures/src/clickhouse_fixture.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use testcontainers::core::wait::HttpWaitStrategy; use testcontainers::core::{IntoContainerPort, WaitFor}; use testcontainers::runners::AsyncRunner; @@ -9,6 +11,10 @@ use crate::{ContainerFile, ExecOutput, FixtureCredentials, FixtureError, Fixture const HTTP_PORT: u16 = ClickHouseFixture::HTTP_PORT; const HTTPS_PORT: u16 = ClickHouseFixture::HTTPS_PORT; const PASSWORD_USER: &str = "tablepro"; +/// The image starts the server once to create CLICKHOUSE_DB, stops it and +/// starts it again before /ping answers; on a busy CI runner that has +/// taken longer than testcontainers' default of one minute. +const STARTUP_TIMEOUT: Duration = Duration::from_secs(180); pub struct ClickHouseFixture { container: ContainerAsync, @@ -45,6 +51,7 @@ impl ClickHouseFixture { .with_exposed_port(HTTP_PORT.tcp()) .with_exposed_port(HTTPS_PORT.tcp()) .with_wait_for(WaitFor::http(ready)) + .with_startup_timeout(STARTUP_TIMEOUT) .with_env_var("CLICKHOUSE_DB", "tablepro"); let network_alias = network.map(|network| network.container_name(Self::NETWORK_ROLE)); if let (Some(network), Some(alias)) = (network, &network_alias) {