mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 16:02:57 +00:00
* feat(datatables): put a data table's connection under Postgres roles A data table backed by the instance database resolved to exactly one Postgres connection, `custom_instance_user`, for everyone who could reach it at all. There was no way to say this job reads, that one writes, this one never sees the salaries table. A data table role is now a real Postgres login on the cluster, defined once for the instance by a superadmin and named exactly as they named it. A script that declares `-- role analytics` connects as `analytics`, and Postgres decides what it may touch — grants are ordinary SQL. Windmill answers only "may this caller ask for this role", from the tenant lists on the data table entry: `u/alice`, `g/analysts`, `f/finance` or `*`. A data table with no `permissions` block behaves exactly as before. Everything that opens a connection on someone's behalf goes through one chokepoint, `get_datatable_resource_from_db`, which takes the identity explicitly and fails closed when there is none. The role logs in as itself — never `SET ROLE`, which a script could `RESET ROLE` its way out of. A fork's data table entry becomes a pointer at the workspace that governs it rather than a copy of it. The settings clone used to hand a fork a byte-identical entry naming the parent's database, which a fork admin could edit to grant themselves `admin` there; a pointer has nothing local to edit, and its tenants are evaluated as a member of the governing workspace, by email. `permissions` is stripped from the workspace export and ignored on import: tenants name principals of one workspace, and a settings push is not where an access decision should be made. Operations that see the whole database whatever the roles grant stay with the governing workspace's admins: editing the roles, a migration that declares none, and opening a replication stream for a Postgres trigger or capture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): gate the paths that reach a whole database as admin Auditing what still resolved through the unchecked resolver turned up three that act for a caller and hand back the admin connection: `resolve_pg_source_checked` (behind schema export, the full-schema read, database creation, import and the forked-database drop), the connection test, and the schema snapshot a fork clone takes of its parent. On a data table under roles each let any workspace member — or a fork admin who is nobody in the governing workspace — read or copy the whole database whatever its roles grant. All three now require admin reach on the governing workspace. A dump taken under a restricted role would be a silently truncated copy rather than an error, so refusing is the only right answer for the copy paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): confine roles to the instance database, and stop a fork reaching the parent's bookkeeping A data table role is a login on Windmill's own Postgres. Nothing stopped a workspace admin putting a *resource-backed* data table under roles, at which point the executor dialled the host that resource names — one the admin chose — with the role's real cluster password, and `CONNECT` is granted to every registered instance database. Both ends now refuse: the permissions endpoint rejects the save, and the chokepoint refuses to substitute credentials on a non-instance entry rather than trusting the record it read. Two more places reached the governing database without answering to it. The initial-migration generator returned a `pg_dump` of the whole schema to any member. And the migration rename/delete cascade followed a fork's pointer into the parent, so a fork admin renaming or removing their own local entry relabelled or wiped the parent's `_wm_migrations` — after which the parent re-runs every migration from zero. The remote half is now skipped when the entry resolves into another workspace, which is also just correct: a fork renaming what it calls a data table changes nothing about the data table. Also: revoking a tenant now bounces the replication streams of every workspace holding an entry that resolves here, not only the governing one, so a fork's trigger stops rather than living on inside its open connection; the instance role catalog and the governing workspace's tenant lists are no longer returned to someone who cannot edit them; and the tenant rename dedup collapses non-adjacent duplicates, per role rather than once any role changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): fail loudly where a role or a pointer can be left half-recorded Three ways the feature could end up in a state nobody could see or undo. Creating a role writes the cluster first and the catalog second, but the catalog write was an `UPDATE` that matched nothing when the instance Postgres settings row was absent — leaving a live login with a password nobody recorded: invisible to the catalog, un-recreatable because the name is taken, and un-deletable because there is no entry to delete. It now errors, so the operation is retryable once the row is restored. Deleting a workspace only nulls the fork lineage; the data table entries pointing at it are left resolving to nothing. Sweeping them is not an option — turning a pointer back into a copy would hand each fork the database outright — so the delete now names the data tables it stranded, and resolving one says which workspace is missing rather than reporting a data table this workspace never had. `InstanceDatatableRole` derived `Debug` while holding a Postgres password; it is now hand-written so `{:?}` on the catalog cannot put a live credential in a log line. Adds the two branches the reviews found unpinned: a caller who is not a member of the governing workspace at all, and `NoIdentity` — the compatibility path for an agent worker that predates this and sends no job id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): unbreak two operator messages and two comments that described other code The two strings this branch added for states an operator hits once — the catalog write that matched nothing, and the delete that stranded a pointer — were collapsed from their multi-line form with the indentation left in, so both rendered with a fourteen-space gap mid-sentence. `list_datatables` claimed to report a chain it cannot follow and then dropped it; it does drop it, and the comment now says why that is the right place to stay quiet. The non-superadmin check in `edit_datatable_config` was introduced as also covering references, which it does not and need not: `reference` is overwritten from the stored entry for every caller before the check runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): serialize role catalog mutations, and state each helper's authorization contract The catalog is one JSON document, so create, rename, enable and delete are all read-modify-write. Two concurrent creates read the same snapshot, both succeed in the cluster, and the second write drops the first — leaving a live Postgres login with a password nobody recorded, which is the exact state the delete path exists to prevent. Every mutation now runs in one transaction holding an advisory lock across the read, the cluster DDL and the write, so a lost update cannot happen and a failure rolls the whole thing back. The DDL helpers take that transaction rather than the pool, which is what makes the lock cover them. Their statements moved off `sqlx::raw_sql`: the simple protocol is only needed for genuinely multi-statement SQL, and its future is not `Send`, which an axum handler holding the transaction requires. Each of these is one statement anyway. The new cross-crate surface now says what callers must do. `read_role_catalog` returns plaintext credentials; `create`/`rename`/`set_login`/`drop_instance_role` and `converge_connect_grants` mutate cluster-wide state; `read_datatable_entry` reads a workspace's raw config. All of them are superadmin-gated by their current handlers, but nothing said so at the definition, which is where the next caller looks. Also: the roles table reloads after a failed login toggle instead of leaving it claiming a flip that did not land; the rename affordance is the design-system `Button`, not a raw one; and `resolve_datatable_pg_as_caller` drops a `role` parameter no caller ever filled — browsing resolves as the data table's default until the database manager grows a picker. Why role passwords stay a plain `String` while the instance user's password beside them is a `StringOrSecretRef`, asked three times across reviews: that one is a secret ref because an operator supplies it and may want it from their own backend, while these are minted here and never entered by anyone, so there is nothing for a ref to point at. Encrypting generated secrets at rest is a separate change that would take the replication password with it. Now said at the field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): give the role catalog its own row, out of reach of the config machinery Putting it inside `custom_instance_pg_databases` was the wrong call, and it cost two ways. The catalog serializes a generated Postgres password per role, and that row is the operator-facing instance config, so the passwords reached `get_instance_config` and its YAML editor — a live cluster credential in a response body, a UI field and any log of either. Worse in the other direction: `to_settings_map` strips the catalog, so a full-row upsert of that key writes the row back without it and the catalog is gone, while the cluster keeps every login it described. `custom_instance_replication_pwd` is the precedent and says exactly why — a generated secret, written only by the server, never operator-authored, hidden so the config machinery cannot read, rewrite or drop it. The catalog is the same thing, so it now has the same shape: `datatable_roles`, in `HIDDEN_SETTINGS`, `PROTECTED_SETTINGS` and the agent-worker denylist. No redaction to keep in step with three code paths, and no way for a neighbouring write to take it out. Two races on the same shared documents. `edit_datatable_config` read the stored data tables outside its transaction and then wrote the whole `datatable` document, so a permissions save committing in between was silently rolled back; it now reads under `FOR UPDATE`. And `set_datatable_permissions` validated role ids against the catalog before opening its transaction, so a deletion in between let it write a deleted role back — including as the default, which every later job then fails on; it now holds the catalog lock and the settings row across validation and write. Completes the authorization contracts the previous commit claimed but did not finish: `read_datatable_entry` (which it named and missed), `resolve_governing_datatable`, whose whole job is to answer for a workspace the caller may not belong to, and `converge_connect_grants_with`, which had not inherited its wrapper's. Also the generic Python SDK reference: `_format_py_params` learned the bare `*` last time, but `extract_py_functions` is a second formatter and still rendered `datatable(name, role)`, so code written from that page passed a keyword-only argument positionally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): make the concurrency test pin the handlers, and the contracts describe what is enforced The concurrency test reimplemented the read-modify-write inline, so deleting the lock from all three handlers left it green — it pinned Postgres, not the code it was written for. It now drives `create_datatable_role` twice concurrently and asserts the catalog kept both names. Checked the way the last one should have been: removing the lock from the handler makes it fail with "wmtest_a_… is a live cluster login the catalog forgot". The contracts added last commit were stricter than this PR's own callers, which is worse than none — the next reader sees a rule already broken and learns to ignore it. `read_role_catalog` said superadmin-only while two of its four callers are open to any workspace member, and `converge_connect_grants` said superadmin while `set_datatable_permissions` reaches it as a workspace admin. Both were fine on substance: the rule that actually holds is about the credential never reaching a response, log, audit record or export, not about who may call. They now say that. `read_datatable_entry` gets the same treatment rather than the one the earlier message claimed for it: it is the primitive every resolution goes through, so it is deliberately open, and what must not escape is `permissions` — it names the governing workspace's users, groups and folders. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): close the last ways a role or a pointer can be left pointing at nothing The raw settings readers hand back whatever is in the row, so moving the catalog into its own `global_settings` key protected the config machinery and left `GET /settings/global/datatable_roles` and the settings listing returning every live password. Both now filter that one key. The neighbouring `custom_instance_replication_pwd` has the same shape and is not touched here: it predates this and widening the fix to it is a decision about an operator workflow, not a consequence of this change. Three ways a save could leave something resolving to nothing: A permissioned data table could be moved to a PostgreSQL resource. The block was carried across as a server-owned field, the runtime refuses roles on a resource-backed table, so the save succeeded and every job afterwards failed. Refused instead — turning roles off first is one step, and it keeps discarding an access decision something somebody chose. Renaming a governing data table left every fork pointing at the old name: the data table disappears from their pickers and their jobs stop, with nothing in the renaming workspace to suggest why. The rename now follows into the pointers in the same transaction. Deleting one cannot be followed the same way, so it is reported instead — the response names what it stranded, the way deleting a workspace does, and the fork's own error already says which workspace is gone. Also: `ensure_instance_db_grant_options_unchecked` claimed superadmin while the permissions handler reaches it as a workspace admin (the same class fixed last commit, one instance missed); the role entry kept an `instance_config_schema` derive it no longer needs; `write_role_catalog` was the one writer of that table not stamping `updated_at`; and the concurrency test dropped its roles only on success — a failing run is exactly the one that creates them without recording them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * refactor(datatables): put the role catalog in its own table, not in global_settings Five findings across three rounds were all the same choice. A set of live Postgres credentials was living in `global_settings`, which has generic read, list, write, config-export and CLI round-trip paths that know nothing about what they carry: the passwords reached the instance config and its YAML editor, a full-row upsert of a neighbouring key erased the catalog, `GET /settings/global/{key}` and the settings listing returned them raw, and this round the redaction that fixed the last two turned `wmill instance push` into something that wipes every password — a fix breaking the assumption the previous fix made. `POST /settings/global/datatable_roles` could also empty it outside the lock. The approved plan offered a table or `global_settings`, so this is the other option it already allowed rather than a new design. `datatable_role` is a table: no generic settings path can read it, list it, export it, write it or round-trip it, so none of the five needs a guard. The redaction, the hidden/protected/agent-denylist entries and the JSON document all go with it. One row per role also removes the read-modify-write the concurrency work was about: two concurrent creates are two inserts, and the unique index on `name` is what settles a collision. The advisory lock stays for the one window rows do not cover — `CREATE ROLE` is invisible to another transaction until commit, so without it both creates pass their `pg_roles` check. Also from this round: rename mappings are checked against the configuration they claim to describe, since fork pointers are rewritten from them — a caller could otherwise submit `main -> missing` against an unchanged config and repoint every fork of `main` at a name nothing has, and `A -> B` plus `B -> C` moved what pointed at `A` all the way to `C`. And the warning naming forks a delete stranded reached the response but not the screen: both the data table settings save and the workspace delete now show it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): validate a rename against the save it describes, and re-check under the locks Three from the round, all about deciding on state that could already have moved. A permission save resolved the data table and checked it was instance-backed before taking any lock, then wrote under one. A config save committing in between could move the table onto a PostgreSQL resource — recreating exactly what the transition guard refuses — or rename it, in which case the write targeted a key that no longer existed and reported success having changed nothing. It now re-resolves and re-checks on the locked state. Rename validation checked that the source existed before and the target existed after, which still accepts `main -> decoy` against a save that keeps both: every fork of `main` then follows onto a different data table, silently, because it keeps resolving. The rule is now the actual old-to-new key transition — a source may only survive if another rename took its name, and a target may only pre-exist if another rename freed it. That also stops two sources sharing one target, and it admits a swap, which the previous guard refused: `datatables` is keyed by name, so a swap cannot be done one save at a time, and refusing it was a regression against main. The pointer cascade now runs in two passes through a temporary name, the way the migration cascade one layer down already handles the same shape, so `A -> B` with `B -> C` moves each pointer once from what it named before the save. The tenant mutators say what they are for: they write an access decision for any workspace named, with an arbitrary mutation, and exist for the transaction that frees or renames a principal. Editing a decision on purpose belongs in the permissions endpoint. Carried in the same change: the stranded-fork list is a field rather than a phrase to grep out of a success string; the pointer cascade matches with `EXISTS` instead of a `LIKE` over the whole document, so a workspace whose pointers name something else is not rewritten to a byte-identical value under an exclusive lock; and `InstanceDatatableRole` drops the serde derives left over from the JSON document, one of which would emit `pwd`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): cascade on the leave route that is used, gate migrations before the admin connection, and drop a role atomically The tenant cascade on leaving went onto `/users/leave`. The UI and the generated client call `/workspaces/leave` — a different handler in a different crate with the same name — which deleted the membership and left `u/<username>` in the tenant lists. Leaving and rejoining therefore restored the access the leave was supposed to end, and a later account taking the username would have inherited it. The regression test drives the route the client actually calls; without the fix it fails with "leaving kept the tenant". The migration endpoints authorized too late. `run_datatable_migrations` opened the data table's admin connection, created `_wm_migrations` and read it before reaching the per-migration role check — so with nothing pending, nothing was checked at all. Rollback returned before its check when nothing was applied, and the status endpoint had none. All three now ask, before any connection is opened, whether the caller can reach the data table as any role at all; which role a given migration runs as is still decided per migration, and by the executor after that. Deleting a role committed the cluster drop and the catalog row, then swept the tenant lists in separate transactions. A sweep failing part-way left workspaces naming a role nothing can connect as, while the retry answered `NotFound` because the catalog entry was already gone. The sweep now runs in the same transaction, so the drop, the row and every tenant list commit together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse to copy a data table that is under roles pg_dump carries no roles and the import runs with --no-privileges, so a copied data table arrives owned by the admin connection with no GRANT for any role. The settings clone brings `permissions` across, so the fork's tenants pass Windmill's check, connect as the role they were given, and are denied by Postgres on everything: an entry that reads as configured and answers nothing. Refuse the copy — in the import endpoint before any data moves, and in the fork path the CLI takes. Replaying the source's owners and ACLs into the clone is what lifts this, and is a change of its own. Dropping `permissions` from the copy instead would be the unsafe half, since the copy holds the parent's rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse the clone's database too, not only its data A clone is two endpoints: `create_pg_database` then `import_pg_database`. Only the second refused a data table under roles, so a fork asking to clone one created and registered an empty `wm_fork_…` instance database and then failed — and nothing collects it, since `drop_forked_datatable_databases` only drops entries carrying `forked_from` and no entry names this one. Refuse in both, so the clone stops before a database exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * nit worker error msg * fix pg_dump stuck on version 17 on nix * fix(datatables): refuse a malformed role annotation instead of ignoring it `-- Role operator`, `-- role operator;` and `-- role operator -- why` all failed the annotation parser's exact-match rule, so the query fell through to the data table's default role and ran, silently, under a login the author did not choose. Naming a role exists precisely to not do that. A leading comment whose first word is `role` is now an annotation attempt: the keyword matches case-insensitively, one trailing `;` is tolerated, and anything else is an error naming the line. Only callers that already know the target is a `datatable://` reference ever run this, so ordinary SQL keeps its comments. Also bumps the dev shell's postgres client to 18 — it trailed the server the dev database runs, which takes out every data table export, clone and fork-with-data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse a malformed role query string instead of ignoring it `?Role=analytics`, `?role=` and `?x=1&role=…` all fell through the reference parser's exact-match rule, so the connection resolved to the data table's default role and ran under a login the caller never asked for — the URI half of the same trap as a malformed `-- role` annotation. The key now matches case-insensitively, and anything else in the query string is an error naming it; `role` is the only parameter a reference takes. Callers that only need the entry keep a lenient `datatable_ref_name`, since they never act on the role. The DuckDB `ATTACH` parser propagates it rather than attaching under the default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): carry the role annotation into the row_to_json retry The retry rebuilds its SQL from `pruneComments(code)`, so the leading comment block never reached the second attempt — and with it the `-- role <name>` line that decides which login the query runs as. The retry connected as the data table's default role instead, so a query the first attempt was denied could succeed on the second, reported as "recovered with the row_to_json fix". Carry the leading comment block over. The retry itself is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * chore(datatables): don't mount the roles UI until the ACL editor lands Enforcement ships first. The permissions drawer is what turns roles on, and the catalog section is what creates them — both are only useful once there is a way to grant a role the privileges it needs, which arrives with the ACL editor. Left mounted they would offer a feature whose other half does not exist. The two components are complete and reviewed; only their call sites here are commented out, with a note pointing the follow-up PRs at them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): honour `-- role: x`, and fix the DuckDB attach test Two review findings, both real. `attach_datatable_parses_name_and_role` never compiled: `parse_attach_datatable` returns `Result<Option<_>>` now and one call site kept a single `unwrap`. Its `?Role=analytics` case also asserted a refusal, contradicting the parser in the same commit, which matches the key case-insensitively. Replaced with the cases that are genuinely malformed, and a positive one for the cased key. `-- role: analytics` fell through to the default role — the silent fallback the strict parser exists to remove, for the spelling most likely to be typed. The keyword now accepts an optional colon, attached or spaced, while a word that merely starts with it (`rolebased`) is still not an attempt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): clone a fork's pointer instead of failing after the copy Forking a fork with cloning left an orphan database. The preflight resolves the pointer and sees the governing entry, so both endpoints ran and filled the new database; `apply_forked_datatable` then refused the inherited pointer and rolled the fork back, stranding a registered `wm_fork_*` that no entry names and whose name blocks the retry. Refusing earlier would have been the smaller change, but forking a fork and cloning worked before pointers existed, so it would trade an orphan for a regression. Resolve what the pointer names and write the terminal entry the clone needs: the whole `database` object rather than a patch of its `resource_path`, since a pointer has none, and `reference` removed with it. Also accepts `-- role=x` and `-- Role = x`, two more spellings that fell through to the default role. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): refuse to roll back the catalog while roles exist The down migration dropped the table and left every role behind: live Postgres logins whose passwords only that table carried, so after a revert Windmill could neither use, disable nor delete them, and re-applying could not recreate them because the names were taken. Cleaning up here is not possible either — dropping a role means reassigning what it owns in every instance database, and a migration runs in one — so it now refuses while the catalog is non-empty and says to delete the roles through instance settings, which does the cluster work. Also enforces the instance-only invariant the resolved-pointer clone relies on rather than only asserting it in a comment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * refactor(datatables): settle clonability in one place, before anything is created A clone is three stages a workspace apart — `create_pg_database`, then `import_pg_database`, then `apply_forked_datatable` inside the fork transaction. Only the third can roll back, and `CREATE DATABASE` is not transactional, so any refusal that lives there strands a registered `wm_fork_*` that no entry names and whose name blocks the retry. That orphan has now been fixed three times, most recently reintroduced by a guard added one commit ago. Patching each new refusal into the first endpoint is not the fix; having two places that can refuse is. `ensure_datatable_is_clonable` now answers every reason a copy can be refused and returns what it resolved, and the stage that writes the entry only does the work. Also takes an ACCESS EXCLUSIVE lock before the rollback guard counts, so a role created concurrently cannot slip between the check and the drop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): let a retried clone reclaim its own leftover database A clone creates its target database one request before it copies into it, and the fork that would name it is written a request after that. Any failure in between — a pg_dump error, a bad restore, a dropped connection, the source's roles changing mid-flow — left a registered `wm_fork_*` that no entry names, and every retry then failed on its name. This predates data table roles. `create_pg_database` now reclaims such a leftover before creating: only a `wm_fork_*` database Windmill registered as a data table database and that no data table or ducklake entry names, in any workspace, archived ones included. The drop never terminates connections, so a clone still copying into it makes the reclaim fail instead of being cut off. It is limited to callers who administer the source — reaching it is not enough, since on a data table without roles every member reaches it — and anyone else gets the refusal an existing database always got. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "fix(datatables): let a retried clone reclaim its own leftover database" This reverts commit7dd3275a10. The reclaim tied the caller to the source they administer, but not to the database it dropped. Between another workspace's import and its final fork request, that workspace's target is full, registered, unnamed and has no open connection, so an admin of any instance data table could name it and have it dropped and recreated empty. The victim's fork would then commit pointing at the empty copy. Safe reclaim needs durable clone ownership and serialization with the request that names the database; until then the leftover stays, as it did before this PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(datatables): record the stale clone database as a known limitation A clone is three requests and `CREATE DATABASE` is not transactional, so a failure after the first leaves a registered `wm_fork_*` behind, as it did before data table roles. Accepted for this PR: it is harmless to data and goes away once the clone is a single server-side operation. The comment also records why the obvious fix is wrong: reclaiming the leftover on retry, without durable clone ownership, can drop another workspace's fully copied database between its import and its final fork request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): bounce the streams reading a data table when it is deleted Deleting a governing data table, or the workspace that holds it, only collected the fork pointers it stranded, for the warning. A Postgres trigger or capture already streaming through one of those pointers kept the replication connection it opened while the pointer still resolved, so it went on dispatching the governing database's rows after the fork lost access — until its connection happened to restart. The governing workspace's own streams on a deleted entry did the same. Both deletion paths now bounce the affected listeners inside their own transaction, through the helper a permission change already uses, so a listener that reconnects re-resolves the entry and finds it gone. The helper is split so a caller can pass the (workspace, local name) pairs it already holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): keep the fork schema baseline, and bounce streams on every removal Three fixes from review. `edit_datatable_config` took `forked_from` wholesale from the stored entry, so the fork schema diff's save of an advanced baseline was silently discarded and an applied change was offered again. Whether an entry carries a clone stamp is still carried from the store, since that is what marks its database droppable, but the baseline inside it is now taken from the request. The stranded-pointer warning and the stream bounce ran over the optional `deleted_datatables` hint, which the settings-sync CLI never sends, so removing a governing data table through `wmill` bounced nothing. Removals are now derived from the stored configuration against the saved one. `delete_workspace` read the pointers to bounce before its transaction, so a fork committing a pointer during the deletion was missed. The read now happens inside the transaction, after the workspace row is deleted: a fork's insert key-share locks that row through its parent foreign key, so it is either seen or fails on the missing parent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(datatables): keep Postgres triggers and data table roles apart A replication stream reads every row of every table whatever the data table's roles grant, and its listener checks access only when it connects. Rather than chase every way access can change and bounce the streams each one affects, a data table now carries one or the other: - a Postgres trigger or capture cannot be created on, or connect to, a data table under roles; - roles cannot be turned on while an enabled trigger or a live capture reads the data table, its own or a fork's through its pointer. The refusal names each one to disable. This removes the stream bounces on roles edits and on data table and workspace deletion, and the trigger gate that admitted admins. The fork schema baseline fix from the same review round is kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): refuse a Postgres trigger on a data table under roles when it is saved Creating or editing a trigger that points at a data table under roles was accepted, and its listener then retried the refused connection every 30 seconds forever. The save is now refused, and a trigger that reaches such a data table anyway (re-enabled, or cloned into a fork) is disabled by its listener with the reason, as a missing replication slot is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): disable a data table role before deleting it Deleting a role reassigns and drops what it owns in each registered database on its own connection, and each of those passes commits as it goes. A database failing part-way left the role enabled in the catalog and able to log in, but already stripped in the databases reached before it. The role is now disabled in its own commit first, so a failed delete leaves a disabled role to retry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): serialize roles going on with a stream starting Turning roles on looked for enabled triggers and live captures once, without a lock anything starting a stream also took. A trigger enabled in that window could have its listener connect before roles committed, and a healthy listener never checks again. Both transitions now serialize on one advisory lock: roles going on hold it exclusive while they look, and trigger create, edit and enable, and capture setup and ping hold it shared while they commit. Either the look sees the stream, or the listener connects after roles are committed and refuses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): wait out live listeners, and resolve stored names containing `?` Turning roles on counted a trigger as gone once disabled, and a capture once its client stopped pinging, but the listener keeps its replication connection until its next heartbeat notices. A trigger or capture whose listener pinged in the last 15 seconds, the window a server holds a listener for, now still counts as streaming. Data table names could contain `?` before they were restricted, and such entries are still stored. Splitting `?role=` off a reference misread them: `a?b` became `a` with an unknown parameter, and the clone checks looked at a different entry than the one copied. An entry stored under the whole reference is now looked up first, in the Postgres executor, DuckDB ATTACH and the clone checks. Agent workers cannot read the workspace and keep the strict parse, which refuses such a name rather than misreading it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): warn when a settings sync strands fork pointers A settings save reported the fork pointers left resolving to nothing only for the names in `deleted_datatables`, which `wmill sync push` never sends. The save now works out what it removed from the locked entries, and the CLI prints the stranded pointers it returns. Also correct the replication helper's contract: no role or admin check makes a replication connection safe, so a data table under roles is refused outright rather than gated as an admin operation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): refuse a save that drops a data table's roles through an undeclared rename A data table's roles follow its entry only through a declared rename. A settings sync sends the whole map and never declares one, so renaming a data table under roles there read as a delete and a new entry on the same database: the new entry carried no roles, and every caller connected as admin. Such a save is now refused, naming both entries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): no entry without roles may newly reach a database under roles The previous guard only caught a new name replacing an entry under roles. A whole-map save could also repoint an existing entry without roles at that database, or another workspace could point one there, and every caller of that entry would connect as admin. The rule is now stated on the saved entries: one that carries no roles and newly points at an instance database any entry under roles uses, in this workspace or another, is refused. A declared rename carries its roles and passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move data table role catalog and resolution to the enterprise edition Roles are an Enterprise Edition feature. The catalog, the Postgres logins, CONNECT convergence, tenant evaluation and the role half of connection resolution move to windmill-ee-private. Every public function keeps its path and signature and forwards through datatable_roles_oss, which re-exports the enterprise implementation or, without it, refuses. Without the enterprise edition a data table under roles, or a caller naming a role, is refused a connection rather than resolved as admin, and the reach and admin-access checks refuse one under roles. A data table not under roles resolves as before in every edition, and an instance database keeps the CONNECT grants it was created with. The catalog lock, the stream lock, the tenant cascades and the permissions stripping stay in OSS: they only restrict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move the data table permissions endpoints to the enterprise edition The permissions read, save and usable-roles handlers move to windmill-ee-private; the routes stay registered and, without the enterprise edition, answer that data table roles are an Enterprise Edition feature. ensure_governs_datatable and ensure_reaches_datatable keep their paths: the first refuses, the second passes a data table not under roles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move the data table role catalog endpoints to the enterprise edition The superadmin list, create, update and delete handlers move to windmill-ee-private. The routes stay registered and, without the enterprise edition, refuse after authentication. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * test(datatables): run the roles tests on the enterprise edition, refusals without it Each test that exercises roles runs with private and enterprise. Two tests run without them: every roles route answers the Enterprise refusal, and a data table saved under roles, or a named role, is refused a connection while one not under roles resolves as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): gate the roles UI mount sites on an enterprise license Both mount sites are still commented out; the gate travels with them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * test(datatables): run the tenant matcher test on the enterprise edition The matcher it covers is enterprise code now, so without the enterprise edition the test hit the stub and failed the default windmill-common run. It runs with private and enterprise, and a counterpart without them asserts that no tenant list covers anyone, the wildcard and a workspace admin included. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * chore: update ee-repo-ref to a1873dbb67f2302b85ff5362f8387b48eccdb607 This commit updates the EE repository reference after PR #783 was merged in windmill-ee-private. Previous ee-repo-ref: 5c853e2c20eca6b748415fc0d6862a6ebfb5fec4 New ee-repo-ref: a1873dbb67f2302b85ff5362f8387b48eccdb607 Automated by sync-ee-ref workflow. * fix(datatables): refuse roles while a same-workspace alias reaches the database Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(datatables): add an ACL editor for data table roles Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(datatables): data table roles in the DB manager and raw apps Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: never add a role to the reference of a data table whose name contains '?' Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read the roles of a data table whose name contains '?' The generated client leaves a '?' in a path param unencoded, so the lookup 404'd and the raw-app picker blocked Start on such a data table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: take every pooled connection before the ACL apply locks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: refresh grant options only after the ACL apply validates its plan Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix(datatables): refuse a reference naming both a legacy data table and a role When a workspace stores both `sales` and a legacy `sales?role=analytics`, the reference resolved to the legacy entry without a role, so browsing `sales` as `analytics` reached another data table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: declare the default role in migrations written for a data table whose name contains '?' Such a data table connects as its default role without naming it, so the migrations the manager wrote for it declared no role and ran as admin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): let CE migrations connect as an explicitly named admin Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: add only missing grant options before an ACL apply, never default privileges Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix(datatables): serialize roles going on with aliases saved from other workspaces Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(datatables): note that legacy names with ? cannot be migrated Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: run one data table ACL apply at a time per server before it connects Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: hold the ACL connection to the database that was authorized Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: build the ACL connection from the authorized data table entry Resolving the settings again could land on a resource with the same database name on another server, which the later entry checks never see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: check ACL read reach against the entry it connects from Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * feat(datatables): put a data table's connection under Postgres roles A data table backed by the instance database resolved to exactly one Postgres connection, `custom_instance_user`, for everyone who could reach it at all. There was no way to say this job reads, that one writes, this one never sees the salaries table. A data table role is now a real Postgres login on the cluster, defined once for the instance by a superadmin and named exactly as they named it. A script that declares `-- role analytics` connects as `analytics`, and Postgres decides what it may touch — grants are ordinary SQL. Windmill answers only "may this caller ask for this role", from the tenant lists on the data table entry: `u/alice`, `g/analysts`, `f/finance` or `*`. A data table with no `permissions` block behaves exactly as before. Everything that opens a connection on someone's behalf goes through one chokepoint, `get_datatable_resource_from_db`, which takes the identity explicitly and fails closed when there is none. The role logs in as itself — never `SET ROLE`, which a script could `RESET ROLE` its way out of. A fork's data table entry becomes a pointer at the workspace that governs it rather than a copy of it. The settings clone used to hand a fork a byte-identical entry naming the parent's database, which a fork admin could edit to grant themselves `admin` there; a pointer has nothing local to edit, and its tenants are evaluated as a member of the governing workspace, by email. `permissions` is stripped from the workspace export and ignored on import: tenants name principals of one workspace, and a settings push is not where an access decision should be made. Operations that see the whole database whatever the roles grant stay with the governing workspace's admins: editing the roles, a migration that declares none, and opening a replication stream for a Postgres trigger or capture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): gate the paths that reach a whole database as admin Auditing what still resolved through the unchecked resolver turned up three that act for a caller and hand back the admin connection: `resolve_pg_source_checked` (behind schema export, the full-schema read, database creation, import and the forked-database drop), the connection test, and the schema snapshot a fork clone takes of its parent. On a data table under roles each let any workspace member — or a fork admin who is nobody in the governing workspace — read or copy the whole database whatever its roles grant. All three now require admin reach on the governing workspace. A dump taken under a restricted role would be a silently truncated copy rather than an error, so refusing is the only right answer for the copy paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): confine roles to the instance database, and stop a fork reaching the parent's bookkeeping A data table role is a login on Windmill's own Postgres. Nothing stopped a workspace admin putting a *resource-backed* data table under roles, at which point the executor dialled the host that resource names — one the admin chose — with the role's real cluster password, and `CONNECT` is granted to every registered instance database. Both ends now refuse: the permissions endpoint rejects the save, and the chokepoint refuses to substitute credentials on a non-instance entry rather than trusting the record it read. Two more places reached the governing database without answering to it. The initial-migration generator returned a `pg_dump` of the whole schema to any member. And the migration rename/delete cascade followed a fork's pointer into the parent, so a fork admin renaming or removing their own local entry relabelled or wiped the parent's `_wm_migrations` — after which the parent re-runs every migration from zero. The remote half is now skipped when the entry resolves into another workspace, which is also just correct: a fork renaming what it calls a data table changes nothing about the data table. Also: revoking a tenant now bounces the replication streams of every workspace holding an entry that resolves here, not only the governing one, so a fork's trigger stops rather than living on inside its open connection; the instance role catalog and the governing workspace's tenant lists are no longer returned to someone who cannot edit them; and the tenant rename dedup collapses non-adjacent duplicates, per role rather than once any role changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): fail loudly where a role or a pointer can be left half-recorded Three ways the feature could end up in a state nobody could see or undo. Creating a role writes the cluster first and the catalog second, but the catalog write was an `UPDATE` that matched nothing when the instance Postgres settings row was absent — leaving a live login with a password nobody recorded: invisible to the catalog, un-recreatable because the name is taken, and un-deletable because there is no entry to delete. It now errors, so the operation is retryable once the row is restored. Deleting a workspace only nulls the fork lineage; the data table entries pointing at it are left resolving to nothing. Sweeping them is not an option — turning a pointer back into a copy would hand each fork the database outright — so the delete now names the data tables it stranded, and resolving one says which workspace is missing rather than reporting a data table this workspace never had. `InstanceDatatableRole` derived `Debug` while holding a Postgres password; it is now hand-written so `{:?}` on the catalog cannot put a live credential in a log line. Adds the two branches the reviews found unpinned: a caller who is not a member of the governing workspace at all, and `NoIdentity` — the compatibility path for an agent worker that predates this and sends no job id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): unbreak two operator messages and two comments that described other code The two strings this branch added for states an operator hits once — the catalog write that matched nothing, and the delete that stranded a pointer — were collapsed from their multi-line form with the indentation left in, so both rendered with a fourteen-space gap mid-sentence. `list_datatables` claimed to report a chain it cannot follow and then dropped it; it does drop it, and the comment now says why that is the right place to stay quiet. The non-superadmin check in `edit_datatable_config` was introduced as also covering references, which it does not and need not: `reference` is overwritten from the stored entry for every caller before the check runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): serialize role catalog mutations, and state each helper's authorization contract The catalog is one JSON document, so create, rename, enable and delete are all read-modify-write. Two concurrent creates read the same snapshot, both succeed in the cluster, and the second write drops the first — leaving a live Postgres login with a password nobody recorded, which is the exact state the delete path exists to prevent. Every mutation now runs in one transaction holding an advisory lock across the read, the cluster DDL and the write, so a lost update cannot happen and a failure rolls the whole thing back. The DDL helpers take that transaction rather than the pool, which is what makes the lock cover them. Their statements moved off `sqlx::raw_sql`: the simple protocol is only needed for genuinely multi-statement SQL, and its future is not `Send`, which an axum handler holding the transaction requires. Each of these is one statement anyway. The new cross-crate surface now says what callers must do. `read_role_catalog` returns plaintext credentials; `create`/`rename`/`set_login`/`drop_instance_role` and `converge_connect_grants` mutate cluster-wide state; `read_datatable_entry` reads a workspace's raw config. All of them are superadmin-gated by their current handlers, but nothing said so at the definition, which is where the next caller looks. Also: the roles table reloads after a failed login toggle instead of leaving it claiming a flip that did not land; the rename affordance is the design-system `Button`, not a raw one; and `resolve_datatable_pg_as_caller` drops a `role` parameter no caller ever filled — browsing resolves as the data table's default until the database manager grows a picker. Why role passwords stay a plain `String` while the instance user's password beside them is a `StringOrSecretRef`, asked three times across reviews: that one is a secret ref because an operator supplies it and may want it from their own backend, while these are minted here and never entered by anyone, so there is nothing for a ref to point at. Encrypting generated secrets at rest is a separate change that would take the replication password with it. Now said at the field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): give the role catalog its own row, out of reach of the config machinery Putting it inside `custom_instance_pg_databases` was the wrong call, and it cost two ways. The catalog serializes a generated Postgres password per role, and that row is the operator-facing instance config, so the passwords reached `get_instance_config` and its YAML editor — a live cluster credential in a response body, a UI field and any log of either. Worse in the other direction: `to_settings_map` strips the catalog, so a full-row upsert of that key writes the row back without it and the catalog is gone, while the cluster keeps every login it described. `custom_instance_replication_pwd` is the precedent and says exactly why — a generated secret, written only by the server, never operator-authored, hidden so the config machinery cannot read, rewrite or drop it. The catalog is the same thing, so it now has the same shape: `datatable_roles`, in `HIDDEN_SETTINGS`, `PROTECTED_SETTINGS` and the agent-worker denylist. No redaction to keep in step with three code paths, and no way for a neighbouring write to take it out. Two races on the same shared documents. `edit_datatable_config` read the stored data tables outside its transaction and then wrote the whole `datatable` document, so a permissions save committing in between was silently rolled back; it now reads under `FOR UPDATE`. And `set_datatable_permissions` validated role ids against the catalog before opening its transaction, so a deletion in between let it write a deleted role back — including as the default, which every later job then fails on; it now holds the catalog lock and the settings row across validation and write. Completes the authorization contracts the previous commit claimed but did not finish: `read_datatable_entry` (which it named and missed), `resolve_governing_datatable`, whose whole job is to answer for a workspace the caller may not belong to, and `converge_connect_grants_with`, which had not inherited its wrapper's. Also the generic Python SDK reference: `_format_py_params` learned the bare `*` last time, but `extract_py_functions` is a second formatter and still rendered `datatable(name, role)`, so code written from that page passed a keyword-only argument positionally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): make the concurrency test pin the handlers, and the contracts describe what is enforced The concurrency test reimplemented the read-modify-write inline, so deleting the lock from all three handlers left it green — it pinned Postgres, not the code it was written for. It now drives `create_datatable_role` twice concurrently and asserts the catalog kept both names. Checked the way the last one should have been: removing the lock from the handler makes it fail with "wmtest_a_… is a live cluster login the catalog forgot". The contracts added last commit were stricter than this PR's own callers, which is worse than none — the next reader sees a rule already broken and learns to ignore it. `read_role_catalog` said superadmin-only while two of its four callers are open to any workspace member, and `converge_connect_grants` said superadmin while `set_datatable_permissions` reaches it as a workspace admin. Both were fine on substance: the rule that actually holds is about the credential never reaching a response, log, audit record or export, not about who may call. They now say that. `read_datatable_entry` gets the same treatment rather than the one the earlier message claimed for it: it is the primitive every resolution goes through, so it is deliberately open, and what must not escape is `permissions` — it names the governing workspace's users, groups and folders. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): close the last ways a role or a pointer can be left pointing at nothing The raw settings readers hand back whatever is in the row, so moving the catalog into its own `global_settings` key protected the config machinery and left `GET /settings/global/datatable_roles` and the settings listing returning every live password. Both now filter that one key. The neighbouring `custom_instance_replication_pwd` has the same shape and is not touched here: it predates this and widening the fix to it is a decision about an operator workflow, not a consequence of this change. Three ways a save could leave something resolving to nothing: A permissioned data table could be moved to a PostgreSQL resource. The block was carried across as a server-owned field, the runtime refuses roles on a resource-backed table, so the save succeeded and every job afterwards failed. Refused instead — turning roles off first is one step, and it keeps discarding an access decision something somebody chose. Renaming a governing data table left every fork pointing at the old name: the data table disappears from their pickers and their jobs stop, with nothing in the renaming workspace to suggest why. The rename now follows into the pointers in the same transaction. Deleting one cannot be followed the same way, so it is reported instead — the response names what it stranded, the way deleting a workspace does, and the fork's own error already says which workspace is gone. Also: `ensure_instance_db_grant_options_unchecked` claimed superadmin while the permissions handler reaches it as a workspace admin (the same class fixed last commit, one instance missed); the role entry kept an `instance_config_schema` derive it no longer needs; `write_role_catalog` was the one writer of that table not stamping `updated_at`; and the concurrency test dropped its roles only on success — a failing run is exactly the one that creates them without recording them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * refactor(datatables): put the role catalog in its own table, not in global_settings Five findings across three rounds were all the same choice. A set of live Postgres credentials was living in `global_settings`, which has generic read, list, write, config-export and CLI round-trip paths that know nothing about what they carry: the passwords reached the instance config and its YAML editor, a full-row upsert of a neighbouring key erased the catalog, `GET /settings/global/{key}` and the settings listing returned them raw, and this round the redaction that fixed the last two turned `wmill instance push` into something that wipes every password — a fix breaking the assumption the previous fix made. `POST /settings/global/datatable_roles` could also empty it outside the lock. The approved plan offered a table or `global_settings`, so this is the other option it already allowed rather than a new design. `datatable_role` is a table: no generic settings path can read it, list it, export it, write it or round-trip it, so none of the five needs a guard. The redaction, the hidden/protected/agent-denylist entries and the JSON document all go with it. One row per role also removes the read-modify-write the concurrency work was about: two concurrent creates are two inserts, and the unique index on `name` is what settles a collision. The advisory lock stays for the one window rows do not cover — `CREATE ROLE` is invisible to another transaction until commit, so without it both creates pass their `pg_roles` check. Also from this round: rename mappings are checked against the configuration they claim to describe, since fork pointers are rewritten from them — a caller could otherwise submit `main -> missing` against an unchanged config and repoint every fork of `main` at a name nothing has, and `A -> B` plus `B -> C` moved what pointed at `A` all the way to `C`. And the warning naming forks a delete stranded reached the response but not the screen: both the data table settings save and the workspace delete now show it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): validate a rename against the save it describes, and re-check under the locks Three from the round, all about deciding on state that could already have moved. A permission save resolved the data table and checked it was instance-backed before taking any lock, then wrote under one. A config save committing in between could move the table onto a PostgreSQL resource — recreating exactly what the transition guard refuses — or rename it, in which case the write targeted a key that no longer existed and reported success having changed nothing. It now re-resolves and re-checks on the locked state. Rename validation checked that the source existed before and the target existed after, which still accepts `main -> decoy` against a save that keeps both: every fork of `main` then follows onto a different data table, silently, because it keeps resolving. The rule is now the actual old-to-new key transition — a source may only survive if another rename took its name, and a target may only pre-exist if another rename freed it. That also stops two sources sharing one target, and it admits a swap, which the previous guard refused: `datatables` is keyed by name, so a swap cannot be done one save at a time, and refusing it was a regression against main. The pointer cascade now runs in two passes through a temporary name, the way the migration cascade one layer down already handles the same shape, so `A -> B` with `B -> C` moves each pointer once from what it named before the save. The tenant mutators say what they are for: they write an access decision for any workspace named, with an arbitrary mutation, and exist for the transaction that frees or renames a principal. Editing a decision on purpose belongs in the permissions endpoint. Carried in the same change: the stranded-fork list is a field rather than a phrase to grep out of a success string; the pointer cascade matches with `EXISTS` instead of a `LIKE` over the whole document, so a workspace whose pointers name something else is not rewritten to a byte-identical value under an exclusive lock; and `InstanceDatatableRole` drops the serde derives left over from the JSON document, one of which would emit `pwd`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): cascade on the leave route that is used, gate migrations before the admin connection, and drop a role atomically The tenant cascade on leaving went onto `/users/leave`. The UI and the generated client call `/workspaces/leave` — a different handler in a different crate with the same name — which deleted the membership and left `u/<username>` in the tenant lists. Leaving and rejoining therefore restored the access the leave was supposed to end, and a later account taking the username would have inherited it. The regression test drives the route the client actually calls; without the fix it fails with "leaving kept the tenant". The migration endpoints authorized too late. `run_datatable_migrations` opened the data table's admin connection, created `_wm_migrations` and read it before reaching the per-migration role check — so with nothing pending, nothing was checked at all. Rollback returned before its check when nothing was applied, and the status endpoint had none. All three now ask, before any connection is opened, whether the caller can reach the data table as any role at all; which role a given migration runs as is still decided per migration, and by the executor after that. Deleting a role committed the cluster drop and the catalog row, then swept the tenant lists in separate transactions. A sweep failing part-way left workspaces naming a role nothing can connect as, while the retry answered `NotFound` because the catalog entry was already gone. The sweep now runs in the same transaction, so the drop, the row and every tenant list commit together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse to copy a data table that is under roles pg_dump carries no roles and the import runs with --no-privileges, so a copied data table arrives owned by the admin connection with no GRANT for any role. The settings clone brings `permissions` across, so the fork's tenants pass Windmill's check, connect as the role they were given, and are denied by Postgres on everything: an entry that reads as configured and answers nothing. Refuse the copy — in the import endpoint before any data moves, and in the fork path the CLI takes. Replaying the source's owners and ACLs into the clone is what lifts this, and is a change of its own. Dropping `permissions` from the copy instead would be the unsafe half, since the copy holds the parent's rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse the clone's database too, not only its data A clone is two endpoints: `create_pg_database` then `import_pg_database`. Only the second refused a data table under roles, so a fork asking to clone one created and registered an empty `wm_fork_…` instance database and then failed — and nothing collects it, since `drop_forked_datatable_databases` only drops entries carrying `forked_from` and no entry names this one. Refuse in both, so the clone stops before a database exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * nit worker error msg * fix pg_dump stuck on version 17 on nix * fix(datatables): refuse a malformed role annotation instead of ignoring it `-- Role operator`, `-- role operator;` and `-- role operator -- why` all failed the annotation parser's exact-match rule, so the query fell through to the data table's default role and ran, silently, under a login the author did not choose. Naming a role exists precisely to not do that. A leading comment whose first word is `role` is now an annotation attempt: the keyword matches case-insensitively, one trailing `;` is tolerated, and anything else is an error naming the line. Only callers that already know the target is a `datatable://` reference ever run this, so ordinary SQL keeps its comments. Also bumps the dev shell's postgres client to 18 — it trailed the server the dev database runs, which takes out every data table export, clone and fork-with-data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): refuse a malformed role query string instead of ignoring it `?Role=analytics`, `?role=` and `?x=1&role=…` all fell through the reference parser's exact-match rule, so the connection resolved to the data table's default role and ran under a login the caller never asked for — the URI half of the same trap as a malformed `-- role` annotation. The key now matches case-insensitively, and anything else in the query string is an error naming it; `role` is the only parameter a reference takes. Callers that only need the entry keep a lenient `datatable_ref_name`, since they never act on the role. The DuckDB `ATTACH` parser propagates it rather than attaching under the default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR * fix(datatables): carry the role annotation into the row_to_json retry The retry rebuilds its SQL from `pruneComments(code)`, so the leading comment block never reached the second attempt — and with it the `-- role <name>` line that decides which login the query runs as. The retry connected as the data table's default role instead, so a query the first attempt was denied could succeed on the second, reported as "recovered with the row_to_json fix". Carry the leading comment block over. The retry itself is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * chore(datatables): don't mount the roles UI until the ACL editor lands Enforcement ships first. The permissions drawer is what turns roles on, and the catalog section is what creates them — both are only useful once there is a way to grant a role the privileges it needs, which arrives with the ACL editor. Left mounted they would offer a feature whose other half does not exist. The two components are complete and reviewed; only their call sites here are commented out, with a note pointing the follow-up PRs at them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): honour `-- role: x`, and fix the DuckDB attach test Two review findings, both real. `attach_datatable_parses_name_and_role` never compiled: `parse_attach_datatable` returns `Result<Option<_>>` now and one call site kept a single `unwrap`. Its `?Role=analytics` case also asserted a refusal, contradicting the parser in the same commit, which matches the key case-insensitively. Replaced with the cases that are genuinely malformed, and a positive one for the cased key. `-- role: analytics` fell through to the default role — the silent fallback the strict parser exists to remove, for the spelling most likely to be typed. The keyword now accepts an optional colon, attached or spaced, while a word that merely starts with it (`rolebased`) is still not an attempt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): clone a fork's pointer instead of failing after the copy Forking a fork with cloning left an orphan database. The preflight resolves the pointer and sees the governing entry, so both endpoints ran and filled the new database; `apply_forked_datatable` then refused the inherited pointer and rolled the fork back, stranding a registered `wm_fork_*` that no entry names and whose name blocks the retry. Refusing earlier would have been the smaller change, but forking a fork and cloning worked before pointers existed, so it would trade an orphan for a regression. Resolve what the pointer names and write the terminal entry the clone needs: the whole `database` object rather than a patch of its `resource_path`, since a pointer has none, and `reference` removed with it. Also accepts `-- role=x` and `-- Role = x`, two more spellings that fell through to the default role. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): refuse to roll back the catalog while roles exist The down migration dropped the table and left every role behind: live Postgres logins whose passwords only that table carried, so after a revert Windmill could neither use, disable nor delete them, and re-applying could not recreate them because the names were taken. Cleaning up here is not possible either — dropping a role means reassigning what it owns in every instance database, and a migration runs in one — so it now refuses while the catalog is non-empty and says to delete the roles through instance settings, which does the cluster work. Also enforces the instance-only invariant the resolved-pointer clone relies on rather than only asserting it in a comment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * refactor(datatables): settle clonability in one place, before anything is created A clone is three stages a workspace apart — `create_pg_database`, then `import_pg_database`, then `apply_forked_datatable` inside the fork transaction. Only the third can roll back, and `CREATE DATABASE` is not transactional, so any refusal that lives there strands a registered `wm_fork_*` that no entry names and whose name blocks the retry. That orphan has now been fixed three times, most recently reintroduced by a guard added one commit ago. Patching each new refusal into the first endpoint is not the fix; having two places that can refuse is. `ensure_datatable_is_clonable` now answers every reason a copy can be refused and returns what it resolved, and the stage that writes the entry only does the work. Also takes an ACCESS EXCLUSIVE lock before the rollback guard counts, so a role created concurrently cannot slip between the check and the drop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): let a retried clone reclaim its own leftover database A clone creates its target database one request before it copies into it, and the fork that would name it is written a request after that. Any failure in between — a pg_dump error, a bad restore, a dropped connection, the source's roles changing mid-flow — left a registered `wm_fork_*` that no entry names, and every retry then failed on its name. This predates data table roles. `create_pg_database` now reclaims such a leftover before creating: only a `wm_fork_*` database Windmill registered as a data table database and that no data table or ducklake entry names, in any workspace, archived ones included. The drop never terminates connections, so a clone still copying into it makes the reclaim fail instead of being cut off. It is limited to callers who administer the source — reaching it is not enough, since on a data table without roles every member reaches it — and anyone else gets the refusal an existing database always got. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "fix(datatables): let a retried clone reclaim its own leftover database" This reverts commit7dd3275a10. The reclaim tied the caller to the source they administer, but not to the database it dropped. Between another workspace's import and its final fork request, that workspace's target is full, registered, unnamed and has no open connection, so an admin of any instance data table could name it and have it dropped and recreated empty. The victim's fork would then commit pointing at the empty copy. Safe reclaim needs durable clone ownership and serialization with the request that names the database; until then the leftover stays, as it did before this PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(datatables): record the stale clone database as a known limitation A clone is three requests and `CREATE DATABASE` is not transactional, so a failure after the first leaves a registered `wm_fork_*` behind, as it did before data table roles. Accepted for this PR: it is harmless to data and goes away once the clone is a single server-side operation. The comment also records why the obvious fix is wrong: reclaiming the leftover on retry, without durable clone ownership, can drop another workspace's fully copied database between its import and its final fork request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): bounce the streams reading a data table when it is deleted Deleting a governing data table, or the workspace that holds it, only collected the fork pointers it stranded, for the warning. A Postgres trigger or capture already streaming through one of those pointers kept the replication connection it opened while the pointer still resolved, so it went on dispatching the governing database's rows after the fork lost access — until its connection happened to restart. The governing workspace's own streams on a deleted entry did the same. Both deletion paths now bounce the affected listeners inside their own transaction, through the helper a permission change already uses, so a listener that reconnects re-resolves the entry and finds it gone. The helper is split so a caller can pass the (workspace, local name) pairs it already holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): keep the fork schema baseline, and bounce streams on every removal Three fixes from review. `edit_datatable_config` took `forked_from` wholesale from the stored entry, so the fork schema diff's save of an advanced baseline was silently discarded and an applied change was offered again. Whether an entry carries a clone stamp is still carried from the store, since that is what marks its database droppable, but the baseline inside it is now taken from the request. The stranded-pointer warning and the stream bounce ran over the optional `deleted_datatables` hint, which the settings-sync CLI never sends, so removing a governing data table through `wmill` bounced nothing. Removals are now derived from the stored configuration against the saved one. `delete_workspace` read the pointers to bounce before its transaction, so a fork committing a pointer during the deletion was missed. The read now happens inside the transaction, after the workspace row is deleted: a fork's insert key-share locks that row through its parent foreign key, so it is either seen or fails on the missing parent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(datatables): keep Postgres triggers and data table roles apart A replication stream reads every row of every table whatever the data table's roles grant, and its listener checks access only when it connects. Rather than chase every way access can change and bounce the streams each one affects, a data table now carries one or the other: - a Postgres trigger or capture cannot be created on, or connect to, a data table under roles; - roles cannot be turned on while an enabled trigger or a live capture reads the data table, its own or a fork's through its pointer. The refusal names each one to disable. This removes the stream bounces on roles edits and on data table and workspace deletion, and the trigger gate that admitted admins. The fork schema baseline fix from the same review round is kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): refuse a Postgres trigger on a data table under roles when it is saved Creating or editing a trigger that points at a data table under roles was accepted, and its listener then retried the refused connection every 30 seconds forever. The save is now refused, and a trigger that reaches such a data table anyway (re-enabled, or cloned into a fork) is disabled by its listener with the reason, as a missing replication slot is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): disable a data table role before deleting it Deleting a role reassigns and drops what it owns in each registered database on its own connection, and each of those passes commits as it goes. A database failing part-way left the role enabled in the catalog and able to log in, but already stripped in the databases reached before it. The role is now disabled in its own commit first, so a failed delete leaves a disabled role to retry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): serialize roles going on with a stream starting Turning roles on looked for enabled triggers and live captures once, without a lock anything starting a stream also took. A trigger enabled in that window could have its listener connect before roles committed, and a healthy listener never checks again. Both transitions now serialize on one advisory lock: roles going on hold it exclusive while they look, and trigger create, edit and enable, and capture setup and ping hold it shared while they commit. Either the look sees the stream, or the listener connects after roles are committed and refuses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): wait out live listeners, and resolve stored names containing `?` Turning roles on counted a trigger as gone once disabled, and a capture once its client stopped pinging, but the listener keeps its replication connection until its next heartbeat notices. A trigger or capture whose listener pinged in the last 15 seconds, the window a server holds a listener for, now still counts as streaming. Data table names could contain `?` before they were restricted, and such entries are still stored. Splitting `?role=` off a reference misread them: `a?b` became `a` with an unknown parameter, and the clone checks looked at a different entry than the one copied. An entry stored under the whole reference is now looked up first, in the Postgres executor, DuckDB ATTACH and the clone checks. Agent workers cannot read the workspace and keep the strict parse, which refuses such a name rather than misreading it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): warn when a settings sync strands fork pointers A settings save reported the fork pointers left resolving to nothing only for the names in `deleted_datatables`, which `wmill sync push` never sends. The save now works out what it removed from the locked entries, and the CLI prints the stranded pointers it returns. Also correct the replication helper's contract: no role or admin check makes a replication connection safe, so a data table under roles is refused outright rather than gated as an admin operation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): refuse a save that drops a data table's roles through an undeclared rename A data table's roles follow its entry only through a declared rename. A settings sync sends the whole map and never declares one, so renaming a data table under roles there read as a delete and a new entry on the same database: the new entry carried no roles, and every caller connected as admin. Such a save is now refused, naming both entries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * fix(datatables): no entry without roles may newly reach a database under roles The previous guard only caught a new name replacing an entry under roles. A whole-map save could also repoint an existing entry without roles at that database, or another workspace could point one there, and every caller of that entry would connect as admin. The rule is now stated on the saved entries: one that carries no roles and newly points at an instance database any entry under roles uses, in this workspace or another, is refused. A declared rename carries its roles and passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move data table role catalog and resolution to the enterprise edition Roles are an Enterprise Edition feature. The catalog, the Postgres logins, CONNECT convergence, tenant evaluation and the role half of connection resolution move to windmill-ee-private. Every public function keeps its path and signature and forwards through datatable_roles_oss, which re-exports the enterprise implementation or, without it, refuses. Without the enterprise edition a data table under roles, or a caller naming a role, is refused a connection rather than resolved as admin, and the reach and admin-access checks refuse one under roles. A data table not under roles resolves as before in every edition, and an instance database keeps the CONNECT grants it was created with. The catalog lock, the stream lock, the tenant cascades and the permissions stripping stay in OSS: they only restrict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move the data table permissions endpoints to the enterprise edition The permissions read, save and usable-roles handlers move to windmill-ee-private; the routes stay registered and, without the enterprise edition, answer that data table roles are an Enterprise Edition feature. ensure_governs_datatable and ensure_reaches_datatable keep their paths: the first refuses, the second passes a data table not under roles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): move the data table role catalog endpoints to the enterprise edition The superadmin list, create, update and delete handlers move to windmill-ee-private. The routes stay registered and, without the enterprise edition, refuse after authentication. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * test(datatables): run the roles tests on the enterprise edition, refusals without it Each test that exercises roles runs with private and enterprise. Two tests run without them: every roles route answers the Enterprise refusal, and a data table saved under roles, or a named role, is refused a connection while one not under roles resolves as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * feat(datatables): gate the roles UI mount sites on an enterprise license Both mount sites are still commented out; the gate travels with them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * test(datatables): run the tenant matcher test on the enterprise edition The matcher it covers is enterprise code now, so without the enterprise edition the test hit the stub and failed the default windmill-common run. It runs with private and enterprise, and a counterpart without them asserts that no tenant list covers anyone, the wildcard and a workspace admin included. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb * chore: update ee-repo-ref to a1873dbb67f2302b85ff5362f8387b48eccdb607 This commit updates the EE repository reference after PR #783 was merged in windmill-ee-private. Previous ee-repo-ref: 5c853e2c20eca6b748415fc0d6862a6ebfb5fec4 New ee-repo-ref: a1873dbb67f2302b85ff5362f8387b48eccdb607 Automated by sync-ee-ref workflow. * fix(datatables): refuse roles while a same-workspace alias reaches the database Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): let CE migrations connect as an explicitly named admin Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): serialize roles going on with aliases saved from other workspaces Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(datatables): note that legacy names with ? cannot be migrated Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(datatables): add an ACL editor for data table roles Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: take every pooled connection before the ACL apply locks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: refresh grant options only after the ACL apply validates its plan Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: add only missing grant options before an ACL apply, never default privileges Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: run one data table ACL apply at a time per server before it connects Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: hold the ACL connection to the database that was authorized Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: build the ACL connection from the authorized data table entry Resolving the settings again could land on a resource with the same database name on another server, which the later entry checks never see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: check ACL read reach against the entry it connects from Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix(datatables): drop a DuckDB data table secret once its ATTACH has used it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(datatables): resolve a workspace's data tables per pointer hop, not per entry Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): hold the parent's settings while a fork points at its data tables Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(datatables): add an ACL editor for data table roles Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: take every pooled connection before the ACL apply locks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: refresh grant options only after the ACL apply validates its plan Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: add only missing grant options before an ACL apply, never default privileges Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: run one data table ACL apply at a time per server before it connects Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: hold the ACL connection to the database that was authorized Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: build the ACL connection from the authorized data table entry Resolving the settings again could land on a resource with the same database name on another server, which the later entry checks never see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * fix: check ACL read reach against the entry it connects from Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb * chore: update ee-repo-ref to 7e338e4dabf91689bfd7fb0333c6534040b17b59 This commit updates the EE repository reference after PR #787 was merged in windmill-ee-private. Previous ee-repo-ref: 0edd40979cf36bfba59323f3f6a0811ae1369cf5 New ee-repo-ref: 7e338e4dabf91689bfd7fb0333c6534040b17b59 Automated by sync-ee-ref workflow. * feat(datatables): clone a data table under roles with its owners and grants (#11120) Claude-Session: https://claude.ai/code/session_01UbrtwiYNfayrmqouBJHwGV Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: open the raw app data table drawer when the workspace has none Selecting the first data table of an empty list passed undefined to the name check, which threw instead of opening the drawer on no data table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): offer cloning a data table under roles where its grants can be replayed The server clones such a data table and replays the source's owners and grants, which only the Enterprise Edition does, so the fork wizard hid both clone options everywhere instead of on a build that cannot replay them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: name the placeholder the empty raw app data drawer renders Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: pin the enterprise refusal the role pickers read as 'not under roles' The server's sentence and the frontend's copy of it were coupled by nothing, so rewording either one turned every role picker on a community build into a failed lookup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): read a roles answer only for the workspace it was asked in A fork and its parent each have their own roles on a data table of the same name, so an answer stamped with the name alone settled the role from the workspace the editor was acting on before. Also derive the AI table creation flag from the data replaced into the editor: data naming no data table left the flag on from before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): keep an instance database a settings save is waiting to name Cleanup for a database whose setup failed took the lock first, read no user, and dropped it while a save blocked on that same lock was about to commit a reference to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): check the workspace stamp in the default database selector too Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): read what a save racing instance-database cleanup committed A transaction blocked on the lock may still roll back, so keeping the database for it stranded one whose name then blocks every retry: it is let through and its outcome read instead. The waiter query also matches this database's locks only, since pg_locks spans the cluster. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): tell a waiting request apart from the workspaces using a database Both callers render what cleanup returns as the workspaces that keep the database, so a waiting request's pid read as one of them. Each now words that case itself, and the give-up comment names where the kept name actually goes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): take the cleanup lock on a connection the pool cannot reclaim A session lock outlives the future holding it, so a cancellation between taking it and releasing it handed a locked session back to the pool, where every later settings save waits on it. Detached, the connection closes when it is dropped and the server releases the lock with the session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(datatables): close the cleanup connection on drop instead of detaching it Detaching released the pool permit while the session stayed alive, so concurrent cleanups waiting on their locks could open as many connections as they liked. Closing on drop covers the same cancellation and keeps them counted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to fd5b8af748f2c985b13e18d9ea30894f3bd7e9a3 This commit updates the EE repository reference after PR #798 was merged in windmill-ee-private. Previous ee-repo-ref: 3145e422d61d580f0a82804f075285c112879da0 New ee-repo-ref: fd5b8af748f2c985b13e18d9ea30894f3bd7e9a3 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
5120 lines
203 KiB
Rust
5120 lines
203 KiB
Rust
use serde::{Deserialize, Deserializer, Serialize};
|
|
use windmill_types::scripts::ScriptLang;
|
|
|
|
use crate::error::pg_error_message;
|
|
|
|
fn deserialize_bool_from_null<'de, D>(deserializer: D) -> Result<bool, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
// MySQL returns integers (0/1) for CASE expressions instead of booleans,
|
|
// so we need to accept both types.
|
|
let v = serde_json::Value::deserialize(deserializer)?;
|
|
match v {
|
|
serde_json::Value::Bool(b) => Ok(b),
|
|
serde_json::Value::Number(n) => Ok(n.as_i64().unwrap_or(0) != 0),
|
|
serde_json::Value::Null => Ok(false),
|
|
_ => Err(serde::de::Error::custom(format!(
|
|
"expected bool or integer, got {}",
|
|
v
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn deserialize_option_bool_from_int<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let v = serde_json::Value::deserialize(deserializer)?;
|
|
match v {
|
|
serde_json::Value::Bool(b) => Ok(Some(b)),
|
|
serde_json::Value::Number(n) => Ok(Some(n.as_i64().unwrap_or(0) != 0)),
|
|
serde_json::Value::Null => Ok(None),
|
|
_ => Err(serde::de::Error::custom(format!(
|
|
"expected bool or integer, got {}",
|
|
v
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn deserialize_string_from_null<'de, D>(deserializer: D) -> Result<String, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
// DuckDB may return booleans for fields that other databases return as strings
|
|
let v = serde_json::Value::deserialize(deserializer)?;
|
|
match v {
|
|
serde_json::Value::Null => Ok(String::new()),
|
|
serde_json::Value::String(s) => Ok(s),
|
|
serde_json::Value::Bool(b) => Ok(b.to_string()),
|
|
other => Ok(other.to_string()),
|
|
}
|
|
}
|
|
|
|
fn deserialize_column_identity_from_null<'de, D>(
|
|
deserializer: D,
|
|
) -> Result<ColumnIdentity, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
// MySQL returns uppercase "YES"/"NO" while the enum expects title case.
|
|
// DuckDB returns a boolean false instead of a string.
|
|
let v = serde_json::Value::deserialize(deserializer)?;
|
|
match v {
|
|
serde_json::Value::Null => Ok(ColumnIdentity::default()),
|
|
serde_json::Value::Bool(_) => Ok(ColumnIdentity::No),
|
|
serde_json::Value::String(s) => match s.to_lowercase().as_str() {
|
|
"no" => Ok(ColumnIdentity::No),
|
|
"yes" | "always" => Ok(ColumnIdentity::Always),
|
|
"by default" => Ok(ColumnIdentity::ByDefault),
|
|
_ => Ok(ColumnIdentity::No),
|
|
},
|
|
_ => Err(serde::de::Error::custom(format!(
|
|
"expected string, bool, or null for isidentity, got {}",
|
|
v
|
|
))),
|
|
}
|
|
}
|
|
|
|
const WM_INTERNAL_PREFIX: &str = "-- WM_INTERNAL_DB_";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DbType {
|
|
Postgresql,
|
|
Mysql,
|
|
MsSqlServer,
|
|
Snowflake,
|
|
Bigquery,
|
|
Duckdb,
|
|
}
|
|
|
|
impl DbType {
|
|
pub fn from_script_lang(lang: &ScriptLang) -> Option<Self> {
|
|
match lang {
|
|
ScriptLang::Postgresql => Some(DbType::Postgresql),
|
|
ScriptLang::Mysql => Some(DbType::Mysql),
|
|
ScriptLang::Mssql => Some(DbType::MsSqlServer),
|
|
ScriptLang::Snowflake => Some(DbType::Snowflake),
|
|
ScriptLang::Bigquery => Some(DbType::Bigquery),
|
|
ScriptLang::DuckDb => Some(DbType::Duckdb),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum ColumnIdentity {
|
|
#[serde(rename = "By Default")]
|
|
ByDefault,
|
|
#[serde(rename = "Always")]
|
|
Always,
|
|
#[serde(rename = "No")]
|
|
No,
|
|
}
|
|
|
|
impl Default for ColumnIdentity {
|
|
fn default() -> Self {
|
|
ColumnIdentity::No
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ColumnDef {
|
|
pub field: String,
|
|
pub datatype: String,
|
|
#[serde(default, deserialize_with = "deserialize_string_from_null")]
|
|
pub defaultvalue: String,
|
|
#[serde(default, deserialize_with = "deserialize_bool_from_null")]
|
|
pub isprimarykey: bool,
|
|
#[serde(default, deserialize_with = "deserialize_column_identity_from_null")]
|
|
pub isidentity: ColumnIdentity,
|
|
#[serde(default, deserialize_with = "deserialize_string_from_null")]
|
|
pub isnullable: String,
|
|
#[serde(default, deserialize_with = "deserialize_bool_from_null")]
|
|
pub isenum: bool,
|
|
#[serde(default)]
|
|
pub ignored: Option<bool>,
|
|
#[serde(default, rename = "hideInsert")]
|
|
pub hide_insert: Option<bool>,
|
|
#[serde(default, rename = "overrideDefaultValue")]
|
|
pub override_default_value: Option<bool>,
|
|
#[serde(default, rename = "defaultUserValue")]
|
|
pub default_user_value: Option<serde_json::Value>,
|
|
#[serde(default, rename = "defaultValueNull")]
|
|
pub default_value_null: Option<bool>,
|
|
}
|
|
|
|
impl ColumnDef {
|
|
pub fn simple(field: &str, datatype: &str) -> Self {
|
|
ColumnDef {
|
|
field: field.to_string(),
|
|
datatype: datatype.to_string(),
|
|
defaultvalue: String::new(),
|
|
isprimarykey: false,
|
|
isidentity: ColumnIdentity::No,
|
|
isnullable: "YES".to_string(),
|
|
isenum: false,
|
|
ignored: None,
|
|
hide_insert: None,
|
|
override_default_value: None,
|
|
default_user_value: None,
|
|
default_value_null: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A simpler column reference used in buildParameters and some query contexts.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SimpleColumn {
|
|
pub field: String,
|
|
pub datatype: String,
|
|
}
|
|
|
|
pub struct SelectOptions {
|
|
pub limit: Option<i64>,
|
|
pub offset: Option<i64>,
|
|
/// DuckLake time-travel: when set (DuckDB only), the read is pinned to this
|
|
/// catalog snapshot via `AT (VERSION => n)`. Ignored for other db types.
|
|
pub version: Option<i64>,
|
|
}
|
|
|
|
/// DuckLake time-travel suffix appended after a table name in a FROM clause.
|
|
/// `n` is a server-controlled `i64` (a snapshot id), so inlining it is
|
|
/// injection-safe. Empty string when unpinned (reads the latest snapshot).
|
|
fn duckdb_version_suffix(version: Option<i64>) -> String {
|
|
match version {
|
|
Some(v) => format!(" AT (VERSION => {})", v),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WM_INTERNAL_DB expansion: marker detection and SQL generation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Deserialize)]
|
|
struct SelectPayload {
|
|
table: String,
|
|
#[serde(rename = "columnDefs")]
|
|
column_defs: Vec<ColumnDef>,
|
|
#[serde(rename = "whereClause")]
|
|
where_clause: Option<String>,
|
|
limit: Option<i64>,
|
|
offset: Option<i64>,
|
|
#[serde(rename = "fixPgIntTypes")]
|
|
fix_pg_int_types: Option<bool>,
|
|
ducklake: Option<String>,
|
|
/// DuckLake snapshot to time-travel the read to (DuckDB only).
|
|
version: Option<i64>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CountPayload {
|
|
table: String,
|
|
#[serde(rename = "columnDefs")]
|
|
column_defs: Vec<ColumnDef>,
|
|
#[serde(rename = "whereClause")]
|
|
where_clause: Option<String>,
|
|
ducklake: Option<String>,
|
|
/// DuckLake snapshot to time-travel the count to (DuckDB only).
|
|
version: Option<i64>,
|
|
}
|
|
|
|
/// `WM_INTERNAL_DB_DUCKLAKE_SNAPSHOTS` payload — lists the time-travel history
|
|
/// of a ducklake table. DuckLake snapshots are catalog-wide commits, so without
|
|
/// a `table` this lists every commit; with one it is scoped to snapshots where
|
|
/// that table exists (see `expand_ducklake_snapshots`).
|
|
#[derive(Deserialize)]
|
|
struct DucklakeSnapshotsPayload {
|
|
ducklake: String,
|
|
/// Schema-qualified table name (e.g. `main.events_daily`) to scope the
|
|
/// history to. Snapshots predating the table's creation are excluded — a
|
|
/// time-travel read can't target a version where the table didn't exist.
|
|
table: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct DeletePayload {
|
|
table: String,
|
|
columns: Vec<ColumnDef>,
|
|
ducklake: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct InsertPayload {
|
|
table: String,
|
|
columns: Vec<ColumnDef>,
|
|
ducklake: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct UpdatePayload {
|
|
table: String,
|
|
column: SimpleColumn,
|
|
columns: Vec<ColumnDef>,
|
|
ducklake: Option<String>,
|
|
}
|
|
|
|
fn wrap_ducklake_query(query: &str, ducklake: &str) -> String {
|
|
let attach = format!(
|
|
"ATTACH 'ducklake://{}' AS dl;USE dl;\n",
|
|
escape_sql_literal(ducklake)
|
|
);
|
|
// Insert after all leading comment lines (matching JS: /^(--.*\n)*/)
|
|
let mut insert_pos = 0;
|
|
for line in query.lines() {
|
|
if line.starts_with("--") {
|
|
insert_pos += line.len() + 1; // +1 for \n
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
// Clamp to string length in case there's no trailing newline
|
|
insert_pos = insert_pos.min(query.len());
|
|
format!("{}{}{}", &query[..insert_pos], attach, &query[insert_pos..])
|
|
}
|
|
|
|
/// Result of expanding a WM_INTERNAL_DB marker.
|
|
#[derive(Debug, PartialEq)]
|
|
pub struct ExpandedQuery {
|
|
pub code: String,
|
|
/// If set, the worker should use this language instead of the original.
|
|
/// Used when the expanded code is in a different language (e.g. BigQuery all-tables uses Bun).
|
|
pub language_override: Option<ScriptLang>,
|
|
}
|
|
|
|
/// Checks if a SQL script is a WM_INTERNAL_DB marker and expands it into real SQL.
|
|
/// Returns `None` if the script is not a marker (regular SQL passthrough).
|
|
/// Returns `Some(Ok(expanded))` on successful expansion.
|
|
/// Returns `Some(Err(msg))` if the marker is detected but malformed.
|
|
pub fn try_expand_internal_db_query(
|
|
code: &str,
|
|
lang: &ScriptLang,
|
|
) -> Option<Result<ExpandedQuery, String>> {
|
|
let first_line = code.lines().next()?;
|
|
if !first_line.starts_with(WM_INTERNAL_PREFIX) {
|
|
return None;
|
|
}
|
|
|
|
let db_type = match DbType::from_script_lang(lang) {
|
|
Some(dt) => dt,
|
|
None => {
|
|
return Some(Err(format!(
|
|
"WM_INTERNAL_DB markers are not supported for language {:?}",
|
|
lang
|
|
)));
|
|
}
|
|
};
|
|
|
|
// Extract operation and JSON: "-- WM_INTERNAL_DB_SELECT {...}"
|
|
let after_prefix = &first_line[WM_INTERNAL_PREFIX.len()..];
|
|
let (op, json_str) = match after_prefix.find(' ') {
|
|
Some(pos) => (&after_prefix[..pos], after_prefix[pos + 1..].trim()),
|
|
None => {
|
|
return Some(Err("WM_INTERNAL_DB marker missing JSON payload".to_string()));
|
|
}
|
|
};
|
|
|
|
let result = match op {
|
|
// Data operations
|
|
"SELECT" => expand_select(json_str, db_type).map(ExpandedQuery::sql),
|
|
"COUNT" => expand_count(json_str, db_type).map(ExpandedQuery::sql),
|
|
"DELETE" => expand_delete(json_str, db_type).map(ExpandedQuery::sql),
|
|
"INSERT" => expand_insert(json_str, db_type).map(ExpandedQuery::sql),
|
|
"UPDATE" => expand_update(json_str, db_type).map(ExpandedQuery::sql),
|
|
// Schema DDL operations
|
|
"DROP_TABLE" => expand_drop_table(json_str, db_type).map(ExpandedQuery::sql),
|
|
"CREATE_TABLE" => expand_create_table(json_str, db_type).map(ExpandedQuery::sql),
|
|
"ALTER_TABLE" => expand_alter_table(json_str, db_type).map(ExpandedQuery::sql),
|
|
"CREATE_SCHEMA" => expand_create_schema(json_str, db_type).map(ExpandedQuery::sql),
|
|
"DROP_SCHEMA" => expand_drop_schema(json_str, db_type).map(ExpandedQuery::sql),
|
|
"RENAME_SCHEMA" => expand_rename_schema(json_str, db_type).map(ExpandedQuery::sql),
|
|
// Metadata queries
|
|
"LOAD_TABLE_METADATA" => expand_load_table_metadata(json_str, db_type),
|
|
"FOREIGN_KEYS" => expand_foreign_keys(json_str, db_type).map(ExpandedQuery::sql),
|
|
"PRIMARY_KEY_CONSTRAINT" => {
|
|
expand_primary_key_constraint(json_str, db_type).map(ExpandedQuery::sql)
|
|
}
|
|
"SNOWFLAKE_PRIMARY_KEYS" => expand_snowflake_primary_keys(json_str).map(ExpandedQuery::sql),
|
|
// DuckLake time-travel: list a ducklake's snapshot history
|
|
"DUCKLAKE_SNAPSHOTS" => {
|
|
expand_ducklake_snapshots(json_str, db_type).map(ExpandedQuery::sql)
|
|
}
|
|
_ => Err(format!("Unknown WM_INTERNAL_DB operation: {}", op)),
|
|
};
|
|
|
|
Some(result)
|
|
}
|
|
|
|
impl ExpandedQuery {
|
|
fn sql(code: String) -> Self {
|
|
Self { code, language_override: None }
|
|
}
|
|
|
|
fn with_language(code: String, lang: ScriptLang) -> Self {
|
|
Self { code, language_override: Some(lang) }
|
|
}
|
|
}
|
|
|
|
fn expand_select(json_str: &str, db_type: DbType) -> Result<String, String> {
|
|
let payload: SelectPayload =
|
|
serde_json::from_str(json_str).map_err(|e| format!("Invalid SELECT payload: {}", e))?;
|
|
|
|
let options =
|
|
SelectOptions { limit: payload.limit, offset: payload.offset, version: payload.version };
|
|
let breaking = payload
|
|
.fix_pg_int_types
|
|
.map(|v| BreakingFeatures { fix_pg_int_types: v });
|
|
|
|
let query = make_select_query(
|
|
&payload.table,
|
|
&payload.column_defs,
|
|
payload.where_clause.as_deref(),
|
|
db_type,
|
|
Some(&options),
|
|
breaking.as_ref(),
|
|
)?;
|
|
|
|
Ok(maybe_wrap_ducklake(query, payload.ducklake.as_deref()))
|
|
}
|
|
|
|
fn expand_count(json_str: &str, db_type: DbType) -> Result<String, String> {
|
|
let payload: CountPayload =
|
|
serde_json::from_str(json_str).map_err(|e| format!("Invalid COUNT payload: {}", e))?;
|
|
|
|
let query = make_count_query(
|
|
db_type,
|
|
&payload.table,
|
|
payload.where_clause.as_deref(),
|
|
&payload.column_defs,
|
|
payload.version,
|
|
)?;
|
|
|
|
Ok(maybe_wrap_ducklake(query, payload.ducklake.as_deref()))
|
|
}
|
|
|
|
/// Expand `DUCKLAKE_SNAPSHOTS` into the catalog's time-travel history. DuckLake
|
|
/// snapshots are catalog-wide commits, so `ducklake_snapshots('dl')` (the alias
|
|
/// `maybe_wrap_ducklake` attaches) lists every version any `AT (VERSION => n)`
|
|
/// read can target, newest first.
|
|
fn expand_ducklake_snapshots(json_str: &str, db_type: DbType) -> Result<String, String> {
|
|
if db_type != DbType::Duckdb {
|
|
return Err("DUCKLAKE_SNAPSHOTS is only supported for DuckDB".to_string());
|
|
}
|
|
let payload: DucklakeSnapshotsPayload = serde_json::from_str(json_str)
|
|
.map_err(|e| format!("Invalid DUCKLAKE_SNAPSHOTS payload: {}", e))?;
|
|
// `dl` is the alias `wrap_ducklake_query` attaches and `USE`s below.
|
|
let query = match &payload.table {
|
|
// Scope to snapshots from the table's first creation onward. A DuckLake
|
|
// table created at snapshot N can't be read before N (the catalog-wide
|
|
// list would otherwise offer impossible versions). The creation snapshot
|
|
// is the earliest whose `changes.tables_created` names the table;
|
|
// COALESCE to 0 (show all) if it is never found.
|
|
Some(table) => {
|
|
let table = escape_sql_literal(table);
|
|
format!(
|
|
"SELECT snapshot_id, snapshot_time FROM ducklake_snapshots('dl') \
|
|
WHERE snapshot_id >= COALESCE((\
|
|
SELECT min(snapshot_id) FROM ducklake_snapshots('dl') \
|
|
WHERE list_contains(changes.tables_created, '{table}')), 0) \
|
|
ORDER BY snapshot_id DESC"
|
|
)
|
|
}
|
|
None => {
|
|
"SELECT snapshot_id, snapshot_time FROM ducklake_snapshots('dl') ORDER BY snapshot_id DESC"
|
|
.to_string()
|
|
}
|
|
};
|
|
Ok(maybe_wrap_ducklake(query, Some(&payload.ducklake)))
|
|
}
|
|
|
|
/// Filter columns to primary keys only; fall back to all columns if none are marked.
|
|
fn pk_columns_or_all(columns: &[ColumnDef]) -> Vec<ColumnDef> {
|
|
let pks: Vec<ColumnDef> = columns.iter().filter(|c| c.isprimarykey).cloned().collect();
|
|
if pks.is_empty() {
|
|
columns.to_vec()
|
|
} else {
|
|
pks
|
|
}
|
|
}
|
|
|
|
fn expand_delete(json_str: &str, db_type: DbType) -> Result<String, String> {
|
|
let payload: DeletePayload =
|
|
serde_json::from_str(json_str).map_err(|e| format!("Invalid DELETE payload: {}", e))?;
|
|
|
|
let where_columns = pk_columns_or_all(&payload.columns);
|
|
let query = make_delete_query(&payload.table, &where_columns, db_type);
|
|
Ok(maybe_wrap_ducklake(query, payload.ducklake.as_deref()))
|
|
}
|
|
|
|
fn expand_insert(json_str: &str, db_type: DbType) -> Result<String, String> {
|
|
let payload: InsertPayload =
|
|
serde_json::from_str(json_str).map_err(|e| format!("Invalid INSERT payload: {}", e))?;
|
|
|
|
let query = make_insert_query(&payload.table, &payload.columns, db_type)?;
|
|
Ok(maybe_wrap_ducklake(query, payload.ducklake.as_deref()))
|
|
}
|
|
|
|
fn expand_update(json_str: &str, db_type: DbType) -> Result<String, String> {
|
|
let payload: UpdatePayload =
|
|
serde_json::from_str(json_str).map_err(|e| format!("Invalid UPDATE payload: {}", e))?;
|
|
|
|
let where_columns = pk_columns_or_all(&payload.columns);
|
|
let where_simple = cols_to_simple(&where_columns);
|
|
let query = make_update_query(&payload.table, &payload.column, &where_simple, db_type);
|
|
Ok(maybe_wrap_ducklake(query, payload.ducklake.as_deref()))
|
|
}
|
|
|
|
fn maybe_wrap_ducklake(query: String, ducklake: Option<&str>) -> String {
|
|
match ducklake {
|
|
Some(dl) => wrap_ducklake_query(&query, dl),
|
|
None => query,
|
|
}
|
|
}
|
|
|
|
pub struct BreakingFeatures {
|
|
pub fix_pg_int_types: bool,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper functions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn normalize_pg_type(typ: &str) -> &str {
|
|
match typ {
|
|
"double precision" => "float8",
|
|
"character varying" => "varchar",
|
|
"time with time zone" => "timetz",
|
|
"time without time zone" => "time",
|
|
"timestamp with time zone" => "timestamptz",
|
|
"timestamp without time zone" => "timestamp",
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
pub fn build_parameters(columns: &[SimpleColumn], db_type: DbType) -> String {
|
|
columns
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, col)| {
|
|
let base_type = col.datatype.split('(').next().unwrap_or(&col.datatype);
|
|
let base_type = if db_type == DbType::Postgresql {
|
|
normalize_pg_type(base_type)
|
|
} else {
|
|
base_type
|
|
};
|
|
match db_type {
|
|
DbType::Postgresql => format!("-- ${} {} ({})", i + 1, col.field, base_type),
|
|
DbType::Mysql => format!("-- :{} ({})", col.field, base_type),
|
|
DbType::MsSqlServer => {
|
|
format!("-- @p{} {} ({})", i + 1, col.field, base_type)
|
|
}
|
|
DbType::Snowflake => format!("-- ? {} ({})", col.field, base_type),
|
|
DbType::Bigquery => format!("-- @{} ({})", col.field, base_type),
|
|
DbType::Duckdb => format!("-- ${} ({})", col.field, base_type),
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn cols_to_simple(cols: &[ColumnDef]) -> Vec<SimpleColumn> {
|
|
cols.iter()
|
|
.map(|c| SimpleColumn { field: c.field.clone(), datatype: c.datatype.clone() })
|
|
.collect()
|
|
}
|
|
|
|
/// DuckDB's `concat` doubles as list concatenation, so a LIST or ARRAY beside a
|
|
/// VARCHAR is a binder error rather than an implicit cast, and quicksearch
|
|
/// concatenates every visible column, so one of them makes the table
|
|
/// unpreviewable. Everything else concatenates as text and stays uncast: the
|
|
/// frontend twin of this predicate feeds app policy digests that a changed
|
|
/// string invalidates.
|
|
fn duckdb_quicksearch_columns(column_defs: &[ColumnDef]) -> String {
|
|
visible_column_defs(column_defs)
|
|
.map(|c| {
|
|
let quoted = render_db_quoted_identifier(&c.field, DbType::Duckdb);
|
|
if c.datatype.trim_end().ends_with(']') {
|
|
format!("CAST({} AS VARCHAR)", quoted)
|
|
} else {
|
|
quoted
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
/// MSSQL `text`, `ntext`, and `image` types cannot be used with the `=` operator.
|
|
/// This function wraps the column/param in CAST(...) when needed.
|
|
fn mssql_needs_cast_for_eq(datatype: &str) -> bool {
|
|
let dt = datatype.to_lowercase();
|
|
let base = dt.split('(').next().unwrap_or(&dt).trim();
|
|
matches!(base, "text" | "ntext" | "image")
|
|
}
|
|
|
|
fn mssql_eq_condition(quoted_field: &str, param: &str, datatype: &str) -> String {
|
|
if mssql_needs_cast_for_eq(datatype) {
|
|
format!(
|
|
"({p} IS NULL AND {f} IS NULL OR CAST({f} AS NVARCHAR(MAX)) = {p})",
|
|
f = quoted_field,
|
|
p = param
|
|
)
|
|
} else {
|
|
format!(
|
|
"({p} IS NULL AND {f} IS NULL OR {f} = {p})",
|
|
f = quoted_field,
|
|
p = param
|
|
)
|
|
}
|
|
}
|
|
|
|
pub fn render_db_quoted_identifier(identifier: &str, db_type: DbType) -> String {
|
|
match db_type {
|
|
DbType::Postgresql | DbType::Snowflake | DbType::Duckdb => {
|
|
format!("\"{}\"", identifier.replace('"', "\"\""))
|
|
}
|
|
DbType::MsSqlServer => format!("[{}]", identifier.replace(']', "]]")),
|
|
DbType::Mysql | DbType::Bigquery => format!("`{}`", identifier.replace('`', "``")),
|
|
}
|
|
}
|
|
|
|
/// Escape a value for safe interpolation inside a SQL single-quoted string literal.
|
|
fn escape_sql_literal(s: &str) -> String {
|
|
s.replace('\'', "''")
|
|
}
|
|
|
|
/// Quote a potentially schema-qualified table name (e.g. "public.users" → "\"public\".\"users\"").
|
|
/// Each dot-separated part is individually quoted using `render_db_quoted_identifier`.
|
|
fn quote_table_name(table: &str, db_type: DbType) -> String {
|
|
table
|
|
.split('.')
|
|
.map(|part| render_db_quoted_identifier(part, db_type))
|
|
.collect::<Vec<_>>()
|
|
.join(".")
|
|
}
|
|
|
|
/// Shorthand alias for `render_db_quoted_identifier`.
|
|
fn qi(identifier: &str, db_type: DbType) -> String {
|
|
render_db_quoted_identifier(identifier, db_type)
|
|
}
|
|
|
|
/// The columns a table preview shows, in the order [`build_visible_field_list`]
|
|
/// renders them.
|
|
fn visible_column_defs(column_defs: &[ColumnDef]) -> impl Iterator<Item = &ColumnDef> {
|
|
column_defs.iter().filter(|c| c.ignored != Some(true))
|
|
}
|
|
|
|
pub fn build_visible_field_list(column_defs: &[ColumnDef], db_type: DbType) -> Vec<String> {
|
|
visible_column_defs(column_defs)
|
|
.map(|c| render_db_quoted_identifier(&c.field, db_type))
|
|
.collect()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SELECT
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn make_snowflake_select_query(
|
|
table: &str,
|
|
column_defs: &[ColumnDef],
|
|
where_clause: Option<&str>,
|
|
options: Option<&SelectOptions>,
|
|
) -> String {
|
|
let limit = options
|
|
.and_then(|o| o.limit)
|
|
.filter(|&l| l != 0)
|
|
.unwrap_or(100);
|
|
let offset = options.and_then(|o| o.offset).unwrap_or(0);
|
|
|
|
let mut headers: Vec<SimpleColumn> =
|
|
vec![SimpleColumn { field: "quicksearch".into(), datatype: "text".into() }];
|
|
|
|
let filtered_columns = build_visible_field_list(column_defs, DbType::Snowflake);
|
|
let select_clause = filtered_columns.join(", ");
|
|
|
|
let mut query = String::from("\n");
|
|
query.push_str(&format!(
|
|
"SELECT {} FROM {}",
|
|
select_clause,
|
|
quote_table_name(table, DbType::Snowflake)
|
|
));
|
|
|
|
// quicksearch condition
|
|
let mut qs_parts: Vec<String> = vec!["LENGTH(?) = 0".to_string()];
|
|
for col in &filtered_columns {
|
|
headers.push(SimpleColumn { field: "quicksearch".into(), datatype: "text".into() });
|
|
qs_parts.push(format!("CONCAT({}) ILIKE CONCAT('%', ?, '%')", col));
|
|
}
|
|
let quicksearch_condition = qs_parts.join(" OR ");
|
|
|
|
if let Some(wc) = where_clause {
|
|
query.push_str(&format!(" WHERE {} AND ({})", wc, quicksearch_condition));
|
|
} else {
|
|
query.push_str(&format!(" WHERE {}", quicksearch_condition));
|
|
}
|
|
|
|
// ORDER BY
|
|
let order_parts: Vec<String> = column_defs
|
|
.iter()
|
|
.map(|col| {
|
|
headers.push(SimpleColumn {
|
|
field: "order_by".into(),
|
|
datatype: "text".into(),
|
|
});
|
|
headers.push(SimpleColumn {
|
|
field: "is_desc".into(),
|
|
datatype: "boolean".into(),
|
|
});
|
|
headers.push(SimpleColumn {
|
|
field: "order_by".into(),
|
|
datatype: "text".into(),
|
|
});
|
|
headers.push(SimpleColumn {
|
|
field: "is_desc".into(),
|
|
datatype: "boolean".into(),
|
|
});
|
|
{
|
|
let escaped = escape_sql_literal(&col.field);
|
|
let quoted = qi(&col.field, DbType::Snowflake);
|
|
format!(
|
|
"CASE WHEN ? = '{}' AND ? = FALSE THEN {} END ASC,\n\t\tCASE WHEN ? = '{}' AND ? = TRUE THEN {} END DESC",
|
|
escaped, quoted, escaped, quoted
|
|
)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
query.push_str(&format!(" ORDER BY {}", order_parts.join(",\n")));
|
|
query.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
|
|
|
|
// Prepend parameters
|
|
let params = build_parameters(&headers, DbType::Snowflake);
|
|
format!("{}\n{}", params, query)
|
|
}
|
|
|
|
fn pg_build_order_by(
|
|
field: &str,
|
|
is_desc: bool,
|
|
text_cast: bool,
|
|
check_is_number: Option<bool>,
|
|
) -> String {
|
|
let quoted = qi(field, DbType::Postgresql);
|
|
let escaped = escape_sql_literal(field);
|
|
let number_check_expr = match check_is_number {
|
|
Some(true) => format!(
|
|
" pg_typeof({})::text IN ('integer', 'bigint', 'smallint', 'numeric', 'real', 'double precision') AND",
|
|
quoted
|
|
),
|
|
Some(false) => format!(
|
|
" pg_typeof({})::text NOT IN ('integer', 'bigint', 'smallint', 'numeric', 'real', 'double precision') AND",
|
|
quoted
|
|
),
|
|
None => String::new(),
|
|
};
|
|
let cast = if text_cast { "::text" } else { "" };
|
|
let desc_suffix = if is_desc { " DESC" } else { "" };
|
|
format!(
|
|
"(CASE WHEN{} $4 = '{}' AND $5 IS {} THEN {}{} END){}",
|
|
number_check_expr, escaped, is_desc, quoted, cast, desc_suffix
|
|
)
|
|
}
|
|
|
|
pub fn make_select_query(
|
|
table: &str,
|
|
column_defs: &[ColumnDef],
|
|
where_clause: Option<&str>,
|
|
db_type: DbType,
|
|
options: Option<&SelectOptions>,
|
|
breaking_features: Option<&BreakingFeatures>,
|
|
) -> Result<String, String> {
|
|
if table.is_empty() {
|
|
return Err("Table name is required".to_string());
|
|
}
|
|
|
|
let params = vec![
|
|
SimpleColumn {
|
|
field: "limit".into(),
|
|
datatype: if db_type == DbType::Bigquery {
|
|
"integer"
|
|
} else {
|
|
"int"
|
|
}
|
|
.into(),
|
|
},
|
|
SimpleColumn {
|
|
field: "offset".into(),
|
|
datatype: if db_type == DbType::Bigquery {
|
|
"integer"
|
|
} else {
|
|
"int"
|
|
}
|
|
.into(),
|
|
},
|
|
SimpleColumn {
|
|
field: "quicksearch".into(),
|
|
datatype: if db_type == DbType::Bigquery {
|
|
"string"
|
|
} else {
|
|
"text"
|
|
}
|
|
.into(),
|
|
},
|
|
SimpleColumn {
|
|
field: "order_by".into(),
|
|
datatype: if db_type == DbType::Bigquery {
|
|
"string"
|
|
} else {
|
|
"text"
|
|
}
|
|
.into(),
|
|
},
|
|
SimpleColumn {
|
|
field: "is_desc".into(),
|
|
datatype: if db_type == DbType::Bigquery {
|
|
"bool"
|
|
} else {
|
|
"boolean"
|
|
}
|
|
.into(),
|
|
},
|
|
];
|
|
|
|
let filtered_columns = build_visible_field_list(column_defs, db_type);
|
|
let select_clause = filtered_columns.join(", ");
|
|
|
|
match db_type {
|
|
DbType::Snowflake => {
|
|
return Ok(make_snowflake_select_query(
|
|
table,
|
|
column_defs,
|
|
where_clause,
|
|
options,
|
|
));
|
|
}
|
|
DbType::Mysql => {
|
|
let mut query = build_parameters(¶ms, db_type);
|
|
query.push('\n');
|
|
|
|
let order_by: String = column_defs
|
|
.iter()
|
|
.map(|col| {
|
|
let escaped = escape_sql_literal(&col.field);
|
|
let quoted = qi(&col.field, db_type);
|
|
format!(
|
|
"\nCASE WHEN :order_by = '{}' AND :is_desc IS false THEN {} END,\nCASE WHEN :order_by = '{}' AND :is_desc IS true THEN {} END DESC",
|
|
escaped, quoted, escaped, quoted
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(",\n");
|
|
|
|
let quicksearch = format!(
|
|
" (:quicksearch = '' OR CONCAT_WS(' ', {}) LIKE CONCAT('%', :quicksearch, '%'))",
|
|
filtered_columns.join(", ")
|
|
);
|
|
|
|
query.push_str(&format!(
|
|
"SELECT {} FROM {}",
|
|
select_clause,
|
|
quote_table_name(table, db_type)
|
|
));
|
|
query.push_str(&format!(
|
|
" WHERE {} {}",
|
|
where_clause
|
|
.map(|wc| format!("{} AND", wc))
|
|
.unwrap_or_default(),
|
|
quicksearch
|
|
));
|
|
query.push_str(&format!(" ORDER BY {}", order_by));
|
|
query.push_str(" LIMIT :limit OFFSET :offset");
|
|
Ok(query)
|
|
}
|
|
DbType::Postgresql => {
|
|
let mut query = build_parameters(¶ms, db_type);
|
|
query.push('\n');
|
|
|
|
let fix_pg = breaking_features
|
|
.map(|bf| bf.fix_pg_int_types)
|
|
.unwrap_or(false);
|
|
|
|
let order_by_parts: Vec<String> = column_defs
|
|
.iter()
|
|
.map(|col| {
|
|
if fix_pg {
|
|
let force_text_cast =
|
|
col.datatype.contains("[]") || col.datatype.to_lowercase() == "json";
|
|
if force_text_cast {
|
|
format!(
|
|
"\n {},\n {}",
|
|
pg_build_order_by(&col.field, false, true, None),
|
|
pg_build_order_by(&col.field, true, true, None)
|
|
)
|
|
} else {
|
|
format!(
|
|
"\n {},\n {},\n {},\n {}",
|
|
pg_build_order_by(&col.field, false, true, Some(false)),
|
|
pg_build_order_by(&col.field, false, false, Some(true)),
|
|
pg_build_order_by(&col.field, true, true, Some(false)),
|
|
pg_build_order_by(&col.field, true, false, Some(true))
|
|
)
|
|
}
|
|
} else {
|
|
format!(
|
|
"\n {},\n {}",
|
|
pg_build_order_by(&col.field, false, true, None),
|
|
pg_build_order_by(&col.field, true, true, None)
|
|
)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let order_by = format!("\n {}", order_by_parts.join(",\n"));
|
|
|
|
let quicksearch = format!(
|
|
"($3 = '' OR CONCAT({}) ILIKE '%' || $3 || '%')",
|
|
filtered_columns.join(", ")
|
|
);
|
|
|
|
query.push_str(&format!(
|
|
"SELECT {} FROM {}\n",
|
|
filtered_columns
|
|
.iter()
|
|
.map(|c| format!("{}::text", c))
|
|
.collect::<Vec<_>>()
|
|
.join(", "),
|
|
quote_table_name(table, db_type)
|
|
));
|
|
query.push_str(&format!(
|
|
" WHERE {} {}\n",
|
|
where_clause
|
|
.map(|wc| format!("{} AND", wc))
|
|
.unwrap_or_default(),
|
|
quicksearch
|
|
));
|
|
query.push_str(&format!(" ORDER BY {}\n", order_by));
|
|
query.push_str(" LIMIT $1 OFFSET $2");
|
|
Ok(query)
|
|
}
|
|
DbType::MsSqlServer => {
|
|
let mut query = build_parameters(¶ms, db_type);
|
|
query.push('\n');
|
|
|
|
let unsortable_types = ["text", "ntext", "image"];
|
|
|
|
let order_by: String = column_defs
|
|
.iter()
|
|
.filter(|col| !unsortable_types.contains(&col.datatype.to_lowercase().as_str()))
|
|
.map(|col| {
|
|
let escaped = escape_sql_literal(&col.field);
|
|
let quoted = qi(&col.field, db_type);
|
|
format!(
|
|
"\n(CASE WHEN @p4 = '{}' AND @p5 = 0 THEN {} END) ASC,\n(CASE WHEN @p4 = '{}' AND @p5 = 1 THEN {} END) DESC",
|
|
escaped, quoted, escaped, quoted
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(",\n");
|
|
|
|
// Filter search columns to exclude unsortable types
|
|
let search_clause: String = filtered_columns
|
|
.iter()
|
|
.filter(|col| {
|
|
let field_name = &col[1..col.len() - 1]; // Remove brackets
|
|
let def = column_defs.iter().find(|c| c.field == field_name);
|
|
!def.map_or(false, |d| {
|
|
unsortable_types.contains(&d.datatype.to_lowercase().as_str())
|
|
})
|
|
})
|
|
.cloned()
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
|
|
let quicksearch = if !search_clause.is_empty() {
|
|
format!(
|
|
" (@p3 = '' OR CONCAT({}) LIKE '%' + @p3 + '%')",
|
|
search_clause
|
|
)
|
|
} else {
|
|
" (@p3 = '')".to_string()
|
|
};
|
|
|
|
query.push_str(&format!(
|
|
"SELECT {} FROM {}",
|
|
select_clause,
|
|
quote_table_name(table, db_type)
|
|
));
|
|
query.push_str(&format!(
|
|
" WHERE {} {}",
|
|
where_clause
|
|
.map(|wc| format!("{} AND", wc))
|
|
.unwrap_or_default(),
|
|
quicksearch
|
|
));
|
|
query.push_str(&format!(" ORDER BY {}", order_by));
|
|
query.push_str(" OFFSET @p2 ROWS FETCH NEXT @p1 ROWS ONLY");
|
|
Ok(query)
|
|
}
|
|
DbType::Bigquery => {
|
|
let mut query = build_parameters(¶ms, db_type);
|
|
query.push('\n');
|
|
|
|
let order_by: String = column_defs
|
|
.iter()
|
|
.map(|col| {
|
|
let escaped = escape_sql_literal(&col.field);
|
|
let quoted = qi(&col.field, db_type);
|
|
let is_complex = col.datatype == "JSON"
|
|
|| col.datatype.starts_with("STRUCT")
|
|
|| col.datatype.starts_with("ARRAY")
|
|
|| col.datatype == "GEOGRAPHY";
|
|
if is_complex {
|
|
format!(
|
|
"\n(CASE WHEN @order_by = '{}' AND @is_desc = false THEN TO_JSON_STRING({}) END) ASC,\n(CASE WHEN @order_by = '{}' AND @is_desc = true THEN TO_JSON_STRING({}) END) DESC",
|
|
escaped, quoted, escaped, quoted
|
|
)
|
|
} else {
|
|
format!(
|
|
"\n(CASE WHEN @order_by = '{}' AND @is_desc = false THEN {} END) ASC,\n(CASE WHEN @order_by = '{}' AND @is_desc = true THEN {} END) DESC",
|
|
escaped, quoted, escaped, quoted
|
|
)
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(",\n");
|
|
|
|
// Build search clause with JSON conversion for complex types
|
|
let search_parts: Vec<String> = filtered_columns
|
|
.iter()
|
|
.map(|col| {
|
|
let field_name = &col[1..col.len() - 1]; // Remove backticks
|
|
let def = column_defs.iter().find(|c| c.field == field_name);
|
|
let is_complex = def.map_or(false, |d| {
|
|
d.datatype == "JSON"
|
|
|| d.datatype.starts_with("STRUCT")
|
|
|| d.datatype.starts_with("ARRAY")
|
|
|| d.datatype == "GEOGRAPHY"
|
|
});
|
|
if is_complex {
|
|
format!("TO_JSON_STRING({})", col)
|
|
} else {
|
|
format!("CAST({} AS STRING)", col)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let quicksearch = format!(
|
|
" (@quicksearch = '' OR REGEXP_CONTAINS(CONCAT({}), '(?i)' || @quicksearch))",
|
|
search_parts.join(",")
|
|
);
|
|
|
|
query.push_str(&format!(
|
|
"SELECT {} FROM {}",
|
|
select_clause,
|
|
quote_table_name(table, db_type)
|
|
));
|
|
query.push_str(&format!(
|
|
" WHERE {} {}",
|
|
where_clause
|
|
.map(|wc| format!("{} AND", wc))
|
|
.unwrap_or_default(),
|
|
quicksearch
|
|
));
|
|
query.push_str(&format!(" ORDER BY {}", order_by));
|
|
query.push_str(" LIMIT @limit OFFSET @offset");
|
|
Ok(query)
|
|
}
|
|
DbType::Duckdb => {
|
|
let mut query = build_parameters(¶ms, db_type);
|
|
query.push('\n');
|
|
|
|
let order_by_parts: Vec<String> = column_defs
|
|
.iter()
|
|
.map(|col| {
|
|
let escaped = escape_sql_literal(&col.field);
|
|
let quoted = qi(&col.field, db_type);
|
|
format!(
|
|
"\n (CASE WHEN $order_by = '{}' AND $is_desc IS false THEN {}::text END),\n (CASE WHEN $order_by = '{}' AND $is_desc IS true THEN {}::text END) DESC",
|
|
escaped, quoted, escaped, quoted
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
let order_by = format!("\n {}", order_by_parts.join(",\n"));
|
|
|
|
let quicksearch = format!(
|
|
"($quicksearch = '' OR CONCAT({}) ILIKE '%' || $quicksearch || '%')",
|
|
duckdb_quicksearch_columns(column_defs)
|
|
);
|
|
|
|
query.push_str(&format!(
|
|
"SELECT {} FROM {}{}\n",
|
|
filtered_columns.join(", "),
|
|
quote_table_name(table, db_type),
|
|
duckdb_version_suffix(options.and_then(|o| o.version))
|
|
));
|
|
query.push_str(&format!(
|
|
" WHERE {} {}\n",
|
|
where_clause
|
|
.map(|wc| format!("{} AND", wc))
|
|
.unwrap_or_default(),
|
|
quicksearch
|
|
));
|
|
query.push_str(&format!(" ORDER BY {}\n", order_by));
|
|
query.push_str(" LIMIT $limit::INT OFFSET $offset::INT");
|
|
Ok(query)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// COUNT
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn make_count_query(
|
|
db_type: DbType,
|
|
table: &str,
|
|
where_clause: Option<&str>,
|
|
column_defs: &[ColumnDef],
|
|
// DuckLake time-travel snapshot (DuckDB only); `None` counts the latest.
|
|
version: Option<i64>,
|
|
) -> Result<String, String> {
|
|
let where_prefix = " WHERE ";
|
|
let and_condition = " AND ";
|
|
let mut quicksearch_condition = String::new();
|
|
|
|
let qs_datatype = if db_type == DbType::Bigquery {
|
|
"string"
|
|
} else {
|
|
"text"
|
|
};
|
|
|
|
let mut query = match db_type {
|
|
DbType::Snowflake => String::new(), // Snowflake rebuilds parameters below
|
|
_ => build_parameters(
|
|
&[SimpleColumn { field: "quicksearch".into(), datatype: qs_datatype.into() }],
|
|
db_type,
|
|
),
|
|
};
|
|
|
|
if db_type != DbType::Snowflake {
|
|
query.push('\n');
|
|
}
|
|
|
|
if let Some(wc) = where_clause {
|
|
quicksearch_condition = format!(" {} AND ", wc);
|
|
}
|
|
|
|
let filtered_columns = build_visible_field_list(column_defs, db_type);
|
|
|
|
match db_type {
|
|
DbType::Mysql => {
|
|
if !filtered_columns.is_empty() {
|
|
quicksearch_condition.push_str(&format!(
|
|
" (:quicksearch = '' OR CONCAT_WS(' ', {}) LIKE CONCAT('%', :quicksearch, '%'))",
|
|
filtered_columns.join(", ")
|
|
));
|
|
} else {
|
|
quicksearch_condition.push_str(" (:quicksearch = '' OR 1 = 1)");
|
|
}
|
|
query.push_str(&format!(
|
|
"SELECT COUNT(*) as count FROM {}",
|
|
quote_table_name(table, db_type)
|
|
));
|
|
}
|
|
DbType::Postgresql => {
|
|
if !filtered_columns.is_empty() {
|
|
quicksearch_condition.push_str(&format!(
|
|
"($1 = '' OR CONCAT({}) ILIKE '%' || $1 || '%')",
|
|
filtered_columns.join(", ")
|
|
));
|
|
} else {
|
|
quicksearch_condition.push_str("($1 = '' OR 1 = 1)");
|
|
}
|
|
query.push_str(&format!(
|
|
"SELECT COUNT(*) as count FROM {}",
|
|
quote_table_name(table, db_type)
|
|
));
|
|
}
|
|
DbType::MsSqlServer => {
|
|
if !filtered_columns.is_empty() {
|
|
quicksearch_condition.push_str(&format!(
|
|
"(@p1 = '' OR CONCAT({}) LIKE '%' + @p1 + '%')",
|
|
filtered_columns.join(", ")
|
|
));
|
|
} else {
|
|
quicksearch_condition.push_str("(@p1 = '' OR 1 = 1)");
|
|
}
|
|
query.push_str(&format!(
|
|
"SELECT COUNT(*) as count FROM {}",
|
|
quote_table_name(table, db_type)
|
|
));
|
|
}
|
|
DbType::Snowflake => {
|
|
if !filtered_columns.is_empty() {
|
|
query = build_parameters(
|
|
&[
|
|
SimpleColumn { field: "quicksearch".into(), datatype: "text".into() },
|
|
SimpleColumn { field: "quicksearch".into(), datatype: "text".into() },
|
|
],
|
|
DbType::Snowflake,
|
|
);
|
|
query.push('\n');
|
|
quicksearch_condition.push_str(&format!(
|
|
"(? = '' OR CONCAT({}) ILIKE '%' || ? || '%')",
|
|
filtered_columns.join(", ")
|
|
));
|
|
} else {
|
|
query = build_parameters(
|
|
&[SimpleColumn { field: "quicksearch".into(), datatype: "text".into() }],
|
|
DbType::Snowflake,
|
|
);
|
|
query.push('\n');
|
|
quicksearch_condition.push_str("(? = '' OR 1 = 1)");
|
|
}
|
|
query.push_str(&format!(
|
|
"SELECT COUNT(*) as count FROM {}",
|
|
quote_table_name(table, db_type)
|
|
));
|
|
}
|
|
DbType::Bigquery => {
|
|
if !filtered_columns.is_empty() {
|
|
let search_clause: String = filtered_columns
|
|
.iter()
|
|
.map(|col| {
|
|
let field_name = &col[1..col.len() - 1]; // Remove backticks
|
|
let def = column_defs.iter().find(|c| c.field == field_name);
|
|
let is_complex = def.map_or(false, |d| {
|
|
d.datatype == "JSON"
|
|
|| d.datatype.starts_with("STRUCT")
|
|
|| d.datatype.starts_with("ARRAY")
|
|
});
|
|
if is_complex {
|
|
format!("TO_JSON_STRING({})", col)
|
|
} else {
|
|
col.clone()
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
quicksearch_condition.push_str(&format!(
|
|
"(@quicksearch = '' OR REGEXP_CONTAINS(CONCAT({}), '(?i)' || @quicksearch))",
|
|
search_clause
|
|
));
|
|
} else {
|
|
quicksearch_condition.push_str("(@quicksearch = '' OR 1 = 1)");
|
|
}
|
|
query.push_str(&format!(
|
|
"SELECT COUNT(*) as count FROM {}",
|
|
quote_table_name(table, db_type)
|
|
));
|
|
}
|
|
DbType::Duckdb => {
|
|
if !filtered_columns.is_empty() {
|
|
quicksearch_condition.push_str(&format!(
|
|
" ($quicksearch = '' OR CONCAT(' ', {}) LIKE CONCAT('%', $quicksearch, '%'))",
|
|
duckdb_quicksearch_columns(column_defs)
|
|
));
|
|
} else {
|
|
quicksearch_condition.push_str(" ($quicksearch = '' OR 1 = 1)");
|
|
}
|
|
query.push_str(&format!(
|
|
"SELECT COUNT(*) as count FROM {}{}",
|
|
quote_table_name(table, db_type),
|
|
duckdb_version_suffix(version)
|
|
));
|
|
}
|
|
}
|
|
|
|
if where_clause.is_some() {
|
|
query.push_str(where_prefix);
|
|
query.push_str(&quicksearch_condition);
|
|
} else {
|
|
if db_type == DbType::MsSqlServer {
|
|
query.push_str(where_prefix);
|
|
} else {
|
|
query.push_str(and_condition);
|
|
}
|
|
query.push_str(&quicksearch_condition);
|
|
}
|
|
|
|
// Replace AND with WHERE for non-mssql when no whereClause
|
|
if where_clause.is_none()
|
|
&& matches!(
|
|
db_type,
|
|
DbType::Mysql
|
|
| DbType::Postgresql
|
|
| DbType::Snowflake
|
|
| DbType::Bigquery
|
|
| DbType::Duckdb
|
|
)
|
|
{
|
|
query = query.replacen(and_condition, where_prefix, 1);
|
|
}
|
|
|
|
Ok(query)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DELETE
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn make_delete_query(table: &str, columns: &[ColumnDef], db_type: DbType) -> String {
|
|
let simple_cols = cols_to_simple(columns);
|
|
let params = match db_type {
|
|
DbType::Snowflake => {
|
|
// Snowflake duplicates each column for IS NULL check + value
|
|
let doubled: Vec<SimpleColumn> = columns
|
|
.iter()
|
|
.flat_map(|c| {
|
|
vec![
|
|
SimpleColumn { field: c.field.clone(), datatype: c.datatype.clone() },
|
|
SimpleColumn { field: c.field.clone(), datatype: c.datatype.clone() },
|
|
]
|
|
})
|
|
.collect();
|
|
build_parameters(&doubled, db_type)
|
|
}
|
|
_ => build_parameters(&simple_cols, db_type),
|
|
};
|
|
|
|
let mut query = params;
|
|
|
|
let qt = quote_table_name(table, db_type);
|
|
|
|
match db_type {
|
|
DbType::Postgresql => {
|
|
let conditions: String = columns
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, c)| {
|
|
let qf = qi(&c.field, db_type);
|
|
format!(
|
|
"(${} IS NULL AND {} IS NULL OR {} = ${})",
|
|
i + 1,
|
|
qf,
|
|
qf,
|
|
i + 1,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n AND ");
|
|
query.push_str(&format!(
|
|
"\nDELETE FROM {} \nWHERE {} RETURNING 1;",
|
|
qt, conditions
|
|
));
|
|
}
|
|
DbType::Mysql => {
|
|
let conditions: String = columns
|
|
.iter()
|
|
.map(|c| {
|
|
let qf = qi(&c.field, db_type);
|
|
format!(
|
|
"(:{} IS NULL AND {} IS NULL OR {} = :{})",
|
|
c.field, qf, qf, c.field
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n AND ");
|
|
query.push_str(&format!("\nDELETE FROM {} \nWHERE {}", qt, conditions));
|
|
}
|
|
DbType::MsSqlServer => {
|
|
let conditions: String = columns
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, c)| {
|
|
let qf = qi(&c.field, db_type);
|
|
let param = format!("@p{}", i + 1);
|
|
mssql_eq_condition(&qf, ¶m, &c.datatype)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n AND ");
|
|
query.push_str(&format!("\nDELETE FROM {} \nWHERE {}", qt, conditions));
|
|
}
|
|
DbType::Snowflake => {
|
|
let conditions: String = columns
|
|
.iter()
|
|
.map(|c| {
|
|
let qf = qi(&c.field, db_type);
|
|
format!("(? = 'null' AND {} IS NULL OR {} = ?)", qf, qf)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n AND ");
|
|
query.push_str(&format!("\nDELETE FROM {} \nWHERE {}", qt, conditions));
|
|
}
|
|
DbType::Bigquery => {
|
|
let conditions: String = columns
|
|
.iter()
|
|
.map(|c| {
|
|
let qf = qi(&c.field, db_type);
|
|
format!(
|
|
"(CAST(@{} AS STRING) = 'null' AND {} IS NULL OR {} = @{})",
|
|
c.field, qf, qf, c.field
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n AND ");
|
|
query.push_str(&format!("\nDELETE FROM {} \nWHERE {}", qt, conditions));
|
|
}
|
|
DbType::Duckdb => {
|
|
let conditions: String = columns
|
|
.iter()
|
|
.map(|c| {
|
|
let qf = qi(&c.field, db_type);
|
|
format!(
|
|
"(${} IS NULL AND {} IS NULL OR {} = ${})",
|
|
c.field, qf, qf, c.field
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n AND ");
|
|
query.push_str(&format!("\nDELETE FROM {} \nWHERE {}", qt, conditions));
|
|
}
|
|
}
|
|
|
|
query
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// INSERT
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn is_truthy(val: &serde_json::Value) -> bool {
|
|
match val {
|
|
serde_json::Value::Null => false,
|
|
serde_json::Value::Bool(b) => *b,
|
|
serde_json::Value::Number(n) => n.as_f64().map_or(false, |f| f != 0.0),
|
|
serde_json::Value::String(s) => !s.is_empty(),
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
fn get_user_default_value(column: &ColumnDef) -> Option<String> {
|
|
if column.default_value_null == Some(true) {
|
|
return Some("NULL".to_string());
|
|
}
|
|
if let Some(ref val) = column.default_user_value {
|
|
if is_truthy(val) {
|
|
return match val {
|
|
// SAFETY: not escaped — this SQL runs against the user's own external database
|
|
// (DB Studio), not Windmill's internal DB. Users can already run arbitrary SQL.
|
|
serde_json::Value::String(s) => Some(format!("'{}'", s)),
|
|
other => Some(other.to_string()),
|
|
};
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn format_insert_values(columns: &[ColumnDef], db_type: DbType, start_index: usize) -> String {
|
|
columns
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, c)| match db_type {
|
|
DbType::Mysql => format!(":{}", c.field),
|
|
DbType::Postgresql => format!("${}", start_index + i),
|
|
DbType::MsSqlServer => format!("@p{}", start_index + i),
|
|
DbType::Snowflake => "?".to_string(),
|
|
DbType::Bigquery => format!("@{}", c.field),
|
|
DbType::Duckdb => format!("${}", c.field),
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
fn format_column_names(columns: &[ColumnDef], db_type: DbType) -> String {
|
|
columns
|
|
.iter()
|
|
.map(|c| qi(&c.field, db_type))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
fn format_default_values(columns: &[ColumnDef]) -> String {
|
|
columns
|
|
.iter()
|
|
.map(|c| {
|
|
let user_default = get_user_default_value(c);
|
|
if c.override_default_value == Some(true) {
|
|
user_default.unwrap_or_default()
|
|
} else {
|
|
user_default.unwrap_or_else(|| c.defaultvalue.clone())
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
fn should_omit_column_in_insert(column: &ColumnDef) -> Result<bool, String> {
|
|
// In JS: if (!column.hideInsert || column.isidentity === ColumnIdentity.Always) return true
|
|
if column.hide_insert != Some(true) || column.isidentity == ColumnIdentity::Always {
|
|
return Ok(true);
|
|
}
|
|
|
|
let has_user_default_value = {
|
|
let has_val = column.default_user_value.as_ref().map_or(
|
|
false,
|
|
|v| !matches!(v, serde_json::Value::String(s) if s.is_empty()),
|
|
);
|
|
has_val || column.default_value_null == Some(true)
|
|
};
|
|
|
|
let has_db_default = !column.defaultvalue.is_empty();
|
|
|
|
if column.isnullable == "NO" {
|
|
if !has_user_default_value && !has_db_default && column.isidentity == ColumnIdentity::No {
|
|
return Err(format!(
|
|
"Column {} is not nullable and has no default value",
|
|
column.field
|
|
));
|
|
}
|
|
if !has_user_default_value && !has_db_default {
|
|
return Ok(column.isidentity != ColumnIdentity::No);
|
|
}
|
|
return Ok(!has_user_default_value && has_db_default);
|
|
} else if column.isnullable == "YES" {
|
|
return Ok(!has_user_default_value);
|
|
}
|
|
|
|
Ok(false)
|
|
}
|
|
|
|
pub fn make_insert_query(
|
|
table: &str,
|
|
columns: &[ColumnDef],
|
|
db_type: DbType,
|
|
) -> Result<String, String> {
|
|
if table.is_empty() {
|
|
return Err("Table name is required".to_string());
|
|
}
|
|
|
|
let columns_insert: Vec<&ColumnDef> = columns
|
|
.iter()
|
|
.filter(|x| {
|
|
x.hide_insert != Some(true)
|
|
&& !(db_type == DbType::Postgresql && x.defaultvalue.starts_with("nextval("))
|
|
})
|
|
.collect();
|
|
|
|
let columns_default: Vec<&ColumnDef> = columns
|
|
.iter()
|
|
.filter(|c| match should_omit_column_in_insert(c) {
|
|
Ok(omit) => !omit,
|
|
Err(_) => false, // Error case handled below
|
|
})
|
|
.collect();
|
|
|
|
// Check for errors in shouldOmitColumnInInsert
|
|
for c in columns {
|
|
should_omit_column_in_insert(c)?;
|
|
}
|
|
|
|
let all_insert_columns: Vec<&ColumnDef> = columns_insert
|
|
.iter()
|
|
.chain(columns_default.iter())
|
|
.copied()
|
|
.collect();
|
|
|
|
let insert_cols_owned: Vec<ColumnDef> = columns_insert.iter().map(|c| (*c).clone()).collect();
|
|
let simple_insert_cols = cols_to_simple(&insert_cols_owned);
|
|
let mut query = build_parameters(&simple_insert_cols, db_type);
|
|
query.push('\n');
|
|
|
|
let should_insert_comma = !columns_default.is_empty();
|
|
let column_names = format_column_names(
|
|
&all_insert_columns
|
|
.iter()
|
|
.map(|c| (*c).clone())
|
|
.collect::<Vec<_>>(),
|
|
db_type,
|
|
);
|
|
let insert_values = format_insert_values(&insert_cols_owned, db_type, 1);
|
|
let default_cols_owned: Vec<ColumnDef> = columns_default.iter().map(|c| (*c).clone()).collect();
|
|
let default_values = format_default_values(&default_cols_owned);
|
|
let comma_or_empty = if should_insert_comma { ", " } else { "" };
|
|
let values_str = format!("{}{}{}", insert_values, comma_or_empty, default_values);
|
|
|
|
let qt = quote_table_name(table, db_type);
|
|
if values_str.trim().is_empty() {
|
|
return Ok(format!("INSERT INTO {} DEFAULT VALUES", qt));
|
|
}
|
|
|
|
query.push_str(&format!(
|
|
"INSERT INTO {} ({}) VALUES ({})",
|
|
qt, column_names, values_str
|
|
));
|
|
Ok(query)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// UPDATE
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn make_update_query(
|
|
table: &str,
|
|
column: &SimpleColumn,
|
|
columns: &[SimpleColumn],
|
|
db_type: DbType,
|
|
) -> String {
|
|
let mut param_list: Vec<SimpleColumn> =
|
|
vec![SimpleColumn { field: "value_to_update".into(), datatype: column.datatype.clone() }];
|
|
|
|
match db_type {
|
|
DbType::Snowflake => {
|
|
for c in columns {
|
|
param_list
|
|
.push(SimpleColumn { field: c.field.clone(), datatype: c.datatype.clone() });
|
|
param_list
|
|
.push(SimpleColumn { field: c.field.clone(), datatype: c.datatype.clone() });
|
|
}
|
|
}
|
|
_ => {
|
|
for c in columns {
|
|
param_list
|
|
.push(SimpleColumn { field: c.field.clone(), datatype: c.datatype.clone() });
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut query = build_parameters(¶m_list, db_type);
|
|
query.push('\n');
|
|
|
|
let qt = quote_table_name(table, db_type);
|
|
let qcol = qi(&column.field, db_type);
|
|
|
|
match db_type {
|
|
DbType::Postgresql => {
|
|
let conditions: String = columns
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, c)| {
|
|
let qf = qi(&c.field, db_type);
|
|
format!(
|
|
"(${} IS NULL AND {} IS NULL OR {} = ${})",
|
|
i + 2,
|
|
qf,
|
|
qf,
|
|
i + 2,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n AND ");
|
|
|
|
query.push_str(&format!(
|
|
"\nUPDATE {} SET {} = $1 \nWHERE {}\tRETURNING 1",
|
|
qt, qcol, conditions
|
|
));
|
|
}
|
|
DbType::Mysql => {
|
|
let conditions: String = columns
|
|
.iter()
|
|
.map(|c| {
|
|
let qf = qi(&c.field, db_type);
|
|
format!(
|
|
"(:{} IS NULL AND {} IS NULL OR {} = :{})",
|
|
c.field, qf, qf, c.field
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n AND ");
|
|
query.push_str(&format!(
|
|
"\nUPDATE {} SET {} = :value_to_update \nWHERE {}",
|
|
qt, qcol, conditions
|
|
));
|
|
}
|
|
DbType::MsSqlServer => {
|
|
let conditions: String = columns
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, c)| {
|
|
let qf = qi(&c.field, db_type);
|
|
let param = format!("@p{}", i + 2);
|
|
mssql_eq_condition(&qf, ¶m, &c.datatype)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n AND ");
|
|
query.push_str(&format!(
|
|
"\nUPDATE {} SET {} = @p1 \nWHERE {}",
|
|
qt, qcol, conditions
|
|
));
|
|
}
|
|
DbType::Snowflake => {
|
|
let conditions: String = columns
|
|
.iter()
|
|
.map(|c| {
|
|
let qf = qi(&c.field, db_type);
|
|
format!("(? = 'null' AND {} IS NULL OR {} = ?)", qf, qf)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n AND ");
|
|
query.push_str(&format!(
|
|
"\nUPDATE {} SET {} = ? \nWHERE {}",
|
|
qt, qcol, conditions
|
|
));
|
|
}
|
|
DbType::Bigquery => {
|
|
let conditions: String = columns
|
|
.iter()
|
|
.map(|c| {
|
|
let qf = qi(&c.field, db_type);
|
|
format!(
|
|
"(CAST(@{} AS STRING) = 'null' AND {} IS NULL OR {} = @{})",
|
|
c.field, qf, qf, c.field
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n AND ");
|
|
query.push_str(&format!(
|
|
"\nUPDATE {} SET {} = @value_to_update \nWHERE {}",
|
|
qt, qcol, conditions
|
|
));
|
|
}
|
|
DbType::Duckdb => {
|
|
let conditions: String = columns
|
|
.iter()
|
|
.map(|c| {
|
|
let qf = qi(&c.field, db_type);
|
|
format!(
|
|
"(${} IS NULL AND {} IS NULL OR {} = ${})",
|
|
c.field, qf, qf, c.field
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n AND ");
|
|
query.push_str(&format!(
|
|
"\nUPDATE {} SET {} = $value_to_update \nWHERE {}",
|
|
qt, qcol, conditions
|
|
));
|
|
}
|
|
}
|
|
|
|
query
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ===========================================================================
|
|
// Schema DDL operations
|
|
// ===========================================================================
|
|
|
|
// --- Payload structs ---
|
|
|
|
#[derive(Deserialize)]
|
|
struct DropTablePayload {
|
|
table: String,
|
|
schema: Option<String>,
|
|
ducklake: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CreateSchemaPayload {
|
|
schema: String,
|
|
ducklake: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct DropSchemaPayload {
|
|
schema: String,
|
|
ducklake: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RenameSchemaPayload {
|
|
schema: String,
|
|
new_schema: String,
|
|
ducklake: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct TableEditorColumn {
|
|
name: String,
|
|
datatype: String,
|
|
#[serde(
|
|
rename = "primaryKey",
|
|
default,
|
|
deserialize_with = "deserialize_option_bool_from_int"
|
|
)]
|
|
primary_key: Option<bool>,
|
|
#[serde(rename = "defaultValue")]
|
|
default_value: Option<String>,
|
|
#[serde(default, deserialize_with = "deserialize_option_bool_from_int")]
|
|
nullable: Option<bool>,
|
|
datatype_length: Option<i64>,
|
|
#[serde(rename = "initialName")]
|
|
#[allow(dead_code)]
|
|
initial_name: Option<String>,
|
|
#[allow(dead_code)]
|
|
default_constraint_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct ForeignKeyColumn {
|
|
#[serde(rename = "sourceColumn")]
|
|
source_column: Option<String>,
|
|
#[serde(rename = "targetColumn")]
|
|
target_column: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct TableEditorForeignKey {
|
|
#[serde(rename = "targetTable")]
|
|
target_table: Option<String>,
|
|
columns: Vec<ForeignKeyColumn>,
|
|
#[serde(rename = "onDelete", default = "default_no_action")]
|
|
on_delete: String,
|
|
#[serde(rename = "onUpdate", default = "default_no_action")]
|
|
on_update: String,
|
|
#[allow(dead_code)]
|
|
fk_constraint_name: Option<String>,
|
|
}
|
|
|
|
fn default_no_action() -> String {
|
|
"NO ACTION".to_string()
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CreateTablePayload {
|
|
name: String,
|
|
columns: Vec<TableEditorColumn>,
|
|
#[serde(rename = "foreignKeys", default)]
|
|
foreign_keys: Vec<TableEditorForeignKey>,
|
|
schema: Option<String>,
|
|
ducklake: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(tag = "kind")]
|
|
enum AlterTableOperation {
|
|
#[serde(rename = "addColumn")]
|
|
AddColumn { column: TableEditorColumn },
|
|
#[serde(rename = "dropColumn")]
|
|
DropColumn { name: String },
|
|
#[serde(rename = "alterColumn")]
|
|
AlterColumn {
|
|
original: TableEditorColumn,
|
|
#[serde(rename = "defaultConstraintName")]
|
|
default_constraint_name: Option<String>,
|
|
changes: serde_json::Value,
|
|
},
|
|
#[serde(rename = "addForeignKey")]
|
|
AddForeignKey {
|
|
#[serde(rename = "foreignKey")]
|
|
foreign_key: TableEditorForeignKey,
|
|
},
|
|
#[serde(rename = "dropForeignKey")]
|
|
DropForeignKey { fk_constraint_name: String },
|
|
#[serde(rename = "renameTable")]
|
|
RenameTable { to: String },
|
|
#[serde(rename = "addPrimaryKey")]
|
|
AddPrimaryKey { columns: Vec<String> },
|
|
#[serde(rename = "dropPrimaryKey")]
|
|
DropPrimaryKey { pk_constraint_name: Option<String> },
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct AlterTablePayload {
|
|
name: String,
|
|
operations: Vec<AlterTableOperation>,
|
|
schema: Option<String>,
|
|
ducklake: Option<String>,
|
|
}
|
|
|
|
// --- Metadata payload structs ---
|
|
|
|
#[derive(Deserialize)]
|
|
struct LoadTableMetadataPayload {
|
|
table: Option<String>,
|
|
/// MySQL requires the database name for metadata queries.
|
|
#[serde(rename = "databaseName")]
|
|
database_name: Option<String>,
|
|
ducklake: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ForeignKeysPayload {
|
|
table: String,
|
|
schema: Option<String>,
|
|
ducklake: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PrimaryKeyConstraintPayload {
|
|
table: String,
|
|
schema: Option<String>,
|
|
ducklake: Option<String>,
|
|
}
|
|
|
|
// --- Helper functions ---
|
|
|
|
fn db_supports_schemas(db_type: DbType) -> bool {
|
|
matches!(
|
|
db_type,
|
|
DbType::Postgresql | DbType::Snowflake | DbType::Bigquery | DbType::Duckdb
|
|
)
|
|
}
|
|
|
|
fn db_supports_transactional_ddl(db_type: DbType) -> bool {
|
|
matches!(db_type, DbType::Postgresql | DbType::MsSqlServer)
|
|
}
|
|
|
|
fn datatype_has_length(datatype: &str) -> bool {
|
|
let dt = datatype.to_lowercase();
|
|
matches!(
|
|
dt.as_str(),
|
|
"varchar"
|
|
| "char"
|
|
| "nvarchar"
|
|
| "nchar"
|
|
| "varbinary"
|
|
| "binary"
|
|
| "bit"
|
|
| "character varying"
|
|
| "character"
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn table_ref(table: &str, schema: Option<&str>, db_type: DbType) -> String {
|
|
if db_supports_schemas(db_type) {
|
|
if let Some(s) = schema.filter(|s| !s.is_empty()) {
|
|
return format!("{}.{}", qi(s.trim(), db_type), qi(table, db_type));
|
|
}
|
|
}
|
|
qi(table, db_type)
|
|
}
|
|
|
|
fn format_default_value(s: &str, datatype: &str, db_type: DbType) -> String {
|
|
if s.is_empty() {
|
|
return String::new();
|
|
}
|
|
if s.starts_with('{') && s.ends_with('}') {
|
|
return s[1..s.len() - 1].to_string();
|
|
}
|
|
let escaped = escape_sql_literal(s);
|
|
// SAFETY: datatype is not escaped — this SQL runs against the user's own external database
|
|
// (DB Studio), not Windmill's internal DB. Users can already run arbitrary SQL.
|
|
if db_type == DbType::Postgresql {
|
|
return format!("CAST('{}' AS {})", escaped, datatype);
|
|
}
|
|
format!("'{}'", escaped)
|
|
}
|
|
|
|
fn render_column_ddl(c: &TableEditorColumn, db_type: DbType, pk_modifier: bool) -> String {
|
|
let datatype = match c.datatype_length {
|
|
Some(l) if datatype_has_length(&c.datatype) => format!("{}({})", c.datatype, l),
|
|
_ => c.datatype.clone(),
|
|
};
|
|
let def_val = c
|
|
.default_value
|
|
.as_deref()
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| format_default_value(s, &datatype, db_type));
|
|
|
|
let mut s = format!("{} {}", qi(&c.name, db_type), datatype);
|
|
if c.nullable != Some(true) {
|
|
s.push_str(" NOT NULL");
|
|
}
|
|
if let Some(ref d) = def_val {
|
|
if !d.is_empty() {
|
|
s.push_str(&format!(" DEFAULT {}", d));
|
|
}
|
|
}
|
|
if pk_modifier {
|
|
s.push_str(" PRIMARY KEY");
|
|
}
|
|
s
|
|
}
|
|
|
|
fn render_fk_ddl(
|
|
fk: &TableEditorForeignKey,
|
|
use_schema: bool,
|
|
db_type: DbType,
|
|
table_name: &str,
|
|
) -> String {
|
|
let source_cols_raw: Vec<&str> = fk
|
|
.columns
|
|
.iter()
|
|
.filter_map(|c| c.source_column.as_deref())
|
|
.collect();
|
|
let target_cols_raw: Vec<&str> = fk
|
|
.columns
|
|
.iter()
|
|
.filter_map(|c| c.target_column.as_deref())
|
|
.collect();
|
|
let target_table_raw = match &fk.target_table {
|
|
Some(t) => {
|
|
if use_schema || !t.contains('.') {
|
|
t.clone()
|
|
} else {
|
|
t.split('.').last().unwrap_or(t).to_string()
|
|
}
|
|
}
|
|
None => String::new(),
|
|
};
|
|
|
|
let mut sql = String::from("CONSTRAINT ");
|
|
let name_parts: Vec<&str> = std::iter::once(table_name)
|
|
.chain(source_cols_raw.iter().map(|c| &c[..c.len().min(10)]))
|
|
.chain(std::iter::once(target_table_raw.as_str()))
|
|
.chain(target_cols_raw.iter().map(|c| &c[..c.len().min(10)]))
|
|
.collect();
|
|
let full_name = format!("fk_{} ", name_parts.join("_").replace('.', "_"));
|
|
let truncated = &full_name[..full_name.len().min(60)];
|
|
sql.push_str(truncated);
|
|
|
|
let source_quoted: Vec<String> = source_cols_raw.iter().map(|c| qi(c, db_type)).collect();
|
|
let target_quoted: Vec<String> = target_cols_raw.iter().map(|c| qi(c, db_type)).collect();
|
|
|
|
sql.push_str(&format!(
|
|
" FOREIGN KEY ({}) REFERENCES {} ({})",
|
|
source_quoted.join(", "),
|
|
quote_table_name(&target_table_raw, db_type),
|
|
target_quoted.join(", ")
|
|
));
|
|
// SAFETY: on_delete/on_update are not validated — this SQL runs against the user's own
|
|
// external database (DB Studio), not Windmill's internal DB. Users can already run arbitrary SQL.
|
|
if fk.on_delete != "NO ACTION" {
|
|
sql.push_str(&format!(" ON DELETE {}", fk.on_delete));
|
|
}
|
|
if fk.on_update != "NO ACTION" {
|
|
sql.push_str(&format!(" ON UPDATE {}", fk.on_update));
|
|
}
|
|
sql
|
|
}
|
|
|
|
// --- Schema DDL expand functions ---
|
|
|
|
fn expand_drop_table(json_str: &str, db_type: DbType) -> Result<String, String> {
|
|
let p: DropTablePayload =
|
|
serde_json::from_str(json_str).map_err(|e| format!("Invalid DROP_TABLE payload: {}", e))?;
|
|
let tref = if db_supports_schemas(db_type) {
|
|
match p.schema.as_deref().filter(|s| !s.is_empty()) {
|
|
Some(s) => format!("{}.{}", qi(s, db_type), qi(&p.table, db_type)),
|
|
None => qi(&p.table, db_type),
|
|
}
|
|
} else {
|
|
qi(&p.table, db_type)
|
|
};
|
|
let query = format!("DROP TABLE {};", tref);
|
|
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
|
|
}
|
|
|
|
fn expand_create_schema(json_str: &str, db_type: DbType) -> Result<String, String> {
|
|
let p: CreateSchemaPayload = serde_json::from_str(json_str)
|
|
.map_err(|e| format!("Invalid CREATE_SCHEMA payload: {}", e))?;
|
|
let query = format!("CREATE SCHEMA {};", qi(&p.schema, db_type));
|
|
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
|
|
}
|
|
|
|
fn expand_drop_schema(json_str: &str, db_type: DbType) -> Result<String, String> {
|
|
let p: DropSchemaPayload = serde_json::from_str(json_str)
|
|
.map_err(|e| format!("Invalid DROP_SCHEMA payload: {}", e))?;
|
|
let query = format!("DROP SCHEMA {} CASCADE;", qi(&p.schema, db_type));
|
|
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
|
|
}
|
|
|
|
fn expand_rename_schema(json_str: &str, db_type: DbType) -> Result<String, String> {
|
|
let p: RenameSchemaPayload = serde_json::from_str(json_str)
|
|
.map_err(|e| format!("Invalid RENAME_SCHEMA payload: {}", e))?;
|
|
if !matches!(db_type, DbType::Postgresql | DbType::Snowflake) || p.ducklake.is_some() {
|
|
return Err(format!(
|
|
"Renaming a schema is not supported on {:?}",
|
|
db_type
|
|
));
|
|
}
|
|
let query = format!(
|
|
"ALTER SCHEMA {} RENAME TO {};",
|
|
qi(&p.schema, db_type),
|
|
qi(&p.new_schema, db_type)
|
|
);
|
|
Ok(query)
|
|
}
|
|
|
|
fn expand_create_table(json_str: &str, db_type: DbType) -> Result<String, String> {
|
|
let p: CreateTablePayload = serde_json::from_str(json_str)
|
|
.map_err(|e| format!("Invalid CREATE_TABLE payload: {}", e))?;
|
|
let query = make_create_table_query(&p, db_type);
|
|
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
|
|
}
|
|
|
|
fn make_create_table_query(p: &CreateTablePayload, db_type: DbType) -> String {
|
|
let pk_count = p
|
|
.columns
|
|
.iter()
|
|
.filter(|c| c.primary_key == Some(true))
|
|
.count();
|
|
let use_schema = db_supports_schemas(db_type);
|
|
let schema = if db_type == DbType::Snowflake {
|
|
Some(p.schema.as_deref().unwrap_or("PUBLIC"))
|
|
} else {
|
|
p.schema.as_deref()
|
|
};
|
|
|
|
let mut lines: Vec<String> = p
|
|
.columns
|
|
.iter()
|
|
.map(|c| {
|
|
let pk_inline = pk_count == 1 && c.primary_key == Some(true);
|
|
let mut line = format!(" {}", render_column_ddl(c, db_type, false));
|
|
if pk_inline {
|
|
if db_type == DbType::Bigquery {
|
|
line.push_str(" PRIMARY KEY NOT ENFORCED");
|
|
} else {
|
|
line.push_str(" PRIMARY KEY");
|
|
}
|
|
}
|
|
line
|
|
})
|
|
.collect();
|
|
|
|
for fk in &p.foreign_keys {
|
|
lines.push(format!(" {}", render_fk_inline(fk, use_schema, db_type)));
|
|
}
|
|
|
|
if pk_count > 1 {
|
|
let pk_cols: Vec<String> = p
|
|
.columns
|
|
.iter()
|
|
.filter(|c| c.primary_key == Some(true))
|
|
.map(|c| qi(&c.name, db_type))
|
|
.collect();
|
|
let mut pk = format!(" PRIMARY KEY ({})", pk_cols.join(", "));
|
|
if db_type == DbType::Bigquery {
|
|
pk.push_str(" NOT ENFORCED");
|
|
}
|
|
lines.push(pk);
|
|
}
|
|
|
|
let tref = if use_schema {
|
|
match schema.filter(|s| !s.is_empty()) {
|
|
Some(s) => format!("{}.{}", qi(s.trim(), db_type), qi(p.name.trim(), db_type)),
|
|
None => qi(p.name.trim(), db_type),
|
|
}
|
|
} else {
|
|
qi(p.name.trim(), db_type)
|
|
};
|
|
|
|
format!("CREATE TABLE {} (\n{}\n);", tref, lines.join(",\n"))
|
|
}
|
|
|
|
/// Render FK for CREATE TABLE (inline, no CONSTRAINT name prefix).
|
|
fn render_fk_inline(fk: &TableEditorForeignKey, use_schema: bool, db_type: DbType) -> String {
|
|
let source_cols: Vec<String> = fk
|
|
.columns
|
|
.iter()
|
|
.filter_map(|c| c.source_column.as_deref().map(|s| qi(s, db_type)))
|
|
.collect();
|
|
let target_cols: Vec<String> = fk
|
|
.columns
|
|
.iter()
|
|
.filter_map(|c| c.target_column.as_deref().map(|s| qi(s, db_type)))
|
|
.collect();
|
|
let target_table = match &fk.target_table {
|
|
Some(t) => {
|
|
let raw = if use_schema || !t.contains('.') {
|
|
t.clone()
|
|
} else {
|
|
t.split('.').last().unwrap_or(t).to_string()
|
|
};
|
|
quote_table_name(&raw, db_type)
|
|
}
|
|
None => String::new(),
|
|
};
|
|
let mut sql = format!(
|
|
"FOREIGN KEY ({}) REFERENCES {} ({})",
|
|
source_cols.join(", "),
|
|
target_table,
|
|
target_cols.join(", ")
|
|
);
|
|
// SAFETY: on_delete/on_update are not validated — this SQL runs against the user's own
|
|
// external database (DB Studio), not Windmill's internal DB. Users can already run arbitrary SQL.
|
|
if fk.on_delete != "NO ACTION" {
|
|
sql.push_str(&format!(" ON DELETE {}", fk.on_delete));
|
|
}
|
|
if fk.on_update != "NO ACTION" {
|
|
sql.push_str(&format!(" ON UPDATE {}", fk.on_update));
|
|
}
|
|
sql
|
|
}
|
|
|
|
// --- ALTER TABLE ---
|
|
|
|
fn expand_alter_table(json_str: &str, db_type: DbType) -> Result<String, String> {
|
|
let p: AlterTablePayload = serde_json::from_str(json_str)
|
|
.map_err(|e| format!("Invalid ALTER_TABLE payload: {}", e))?;
|
|
let queries = make_alter_table_queries(&p, db_type)?;
|
|
if queries.is_empty() {
|
|
return Ok(String::new());
|
|
}
|
|
let joined = queries.join("\n");
|
|
let query = if db_supports_transactional_ddl(db_type) {
|
|
if db_type == DbType::MsSqlServer {
|
|
format!("BEGIN TRANSACTION;\n{}\nCOMMIT TRANSACTION;", joined)
|
|
} else {
|
|
format!("BEGIN;\n{}\nCOMMIT;", joined)
|
|
}
|
|
} else {
|
|
joined
|
|
};
|
|
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
|
|
}
|
|
|
|
fn make_alter_table_queries(p: &AlterTablePayload, db_type: DbType) -> Result<Vec<String>, String> {
|
|
let use_schema = db_supports_schemas(db_type);
|
|
let tref = if use_schema {
|
|
match p.schema.as_deref().filter(|s| !s.is_empty()) {
|
|
Some(s) => format!("{}.{}", qi(s.trim(), db_type), qi(&p.name, db_type)),
|
|
None => qi(&p.name, db_type),
|
|
}
|
|
} else {
|
|
qi(&p.name, db_type)
|
|
};
|
|
|
|
let mut queries = Vec::new();
|
|
|
|
for op in &p.operations {
|
|
match op {
|
|
AlterTableOperation::AddColumn { column } => {
|
|
queries.push(format!(
|
|
"ALTER TABLE {} ADD COLUMN {};",
|
|
tref,
|
|
render_column_ddl(column, db_type, false)
|
|
));
|
|
}
|
|
AlterTableOperation::DropColumn { name } => {
|
|
queries.push(format!(
|
|
"ALTER TABLE {} DROP COLUMN {};",
|
|
tref,
|
|
render_db_quoted_identifier(name, db_type)
|
|
));
|
|
}
|
|
AlterTableOperation::AlterColumn { original, default_constraint_name, changes } => {
|
|
queries.extend(render_alter_column(
|
|
&tref,
|
|
original,
|
|
default_constraint_name.as_deref(),
|
|
changes,
|
|
db_type,
|
|
)?);
|
|
}
|
|
AlterTableOperation::AddForeignKey { foreign_key } => {
|
|
queries.push(format!(
|
|
"ALTER TABLE {} ADD {};",
|
|
tref,
|
|
render_fk_ddl(foreign_key, use_schema, db_type, &p.name)
|
|
));
|
|
}
|
|
AlterTableOperation::DropForeignKey { fk_constraint_name } => {
|
|
if db_type == DbType::Mysql {
|
|
queries.push(format!(
|
|
"ALTER TABLE {} DROP FOREIGN KEY {};",
|
|
tref,
|
|
render_db_quoted_identifier(fk_constraint_name, db_type)
|
|
));
|
|
} else {
|
|
queries.push(format!(
|
|
"ALTER TABLE {} DROP CONSTRAINT {};",
|
|
tref,
|
|
render_db_quoted_identifier(fk_constraint_name, db_type)
|
|
));
|
|
}
|
|
}
|
|
AlterTableOperation::RenameTable { to } => {
|
|
if db_type == DbType::MsSqlServer {
|
|
let old_name = match p.schema.as_deref().filter(|s| !s.is_empty()) {
|
|
Some(s) => format!(
|
|
"{}.{}",
|
|
escape_sql_literal(s.trim()),
|
|
escape_sql_literal(&p.name)
|
|
),
|
|
None => escape_sql_literal(&p.name),
|
|
};
|
|
queries.push(format!(
|
|
"EXEC sp_rename '{}', '{}';",
|
|
old_name,
|
|
escape_sql_literal(to)
|
|
));
|
|
} else {
|
|
let new_ref = if db_type == DbType::Snowflake {
|
|
match p.schema.as_deref().filter(|s| !s.is_empty()) {
|
|
Some(s) => format!("{}.{}", qi(s.trim(), db_type), qi(to, db_type)),
|
|
None => qi(to, db_type),
|
|
}
|
|
} else {
|
|
qi(to, db_type)
|
|
};
|
|
queries.push(format!("ALTER TABLE {} RENAME TO {};", tref, new_ref));
|
|
}
|
|
}
|
|
AlterTableOperation::AddPrimaryKey { columns } => {
|
|
let quoted_cols: Vec<String> = columns.iter().map(|c| qi(c, db_type)).collect();
|
|
if db_type == DbType::Snowflake {
|
|
let cname = format!("{}_pk", p.name);
|
|
queries.push(format!(
|
|
"ALTER TABLE {} ADD CONSTRAINT {} PRIMARY KEY ({});",
|
|
tref,
|
|
qi(&cname, db_type),
|
|
quoted_cols.join(", ")
|
|
));
|
|
} else {
|
|
let not_enforced = if db_type == DbType::Bigquery {
|
|
" NOT ENFORCED"
|
|
} else {
|
|
""
|
|
};
|
|
queries.push(format!(
|
|
"ALTER TABLE {} ADD PRIMARY KEY ({}){};",
|
|
tref,
|
|
quoted_cols.join(", "),
|
|
not_enforced
|
|
));
|
|
}
|
|
}
|
|
AlterTableOperation::DropPrimaryKey { pk_constraint_name } => {
|
|
if db_type == DbType::Mysql || pk_constraint_name.is_none() {
|
|
queries.push(format!("ALTER TABLE {} DROP PRIMARY KEY;", tref));
|
|
} else {
|
|
queries.push(format!(
|
|
"ALTER TABLE {} DROP CONSTRAINT {};",
|
|
tref,
|
|
render_db_quoted_identifier(
|
|
pk_constraint_name.as_deref().unwrap_or(""),
|
|
db_type
|
|
)
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(queries)
|
|
}
|
|
|
|
fn render_alter_column(
|
|
table_ref: &str,
|
|
original: &TableEditorColumn,
|
|
default_constraint_name: Option<&str>,
|
|
changes: &serde_json::Value,
|
|
db_type: DbType,
|
|
) -> Result<Vec<String>, String> {
|
|
let mut queries = Vec::new();
|
|
|
|
let base_datatype = changes
|
|
.get("datatype")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or(&original.datatype);
|
|
let dt_length = if datatype_has_length(base_datatype) {
|
|
changes
|
|
.get("datatype_length")
|
|
.and_then(|v| v.as_i64())
|
|
.or(original.datatype_length)
|
|
} else {
|
|
None
|
|
};
|
|
let datatype = match dt_length {
|
|
Some(l) => format!("{}({})", base_datatype, l),
|
|
None => base_datatype.to_string(),
|
|
};
|
|
|
|
// Alter datatype
|
|
if changes.get("datatype").is_some() || changes.get("datatype_length").is_some() {
|
|
queries.push(render_alter_datatype(
|
|
table_ref,
|
|
&original.name,
|
|
&datatype,
|
|
db_type,
|
|
));
|
|
}
|
|
|
|
// Default value changes
|
|
if let Some(def_val) = changes.get("defaultValue") {
|
|
if def_val.is_null() && original.default_value.is_some() {
|
|
queries.push(render_drop_default_value(
|
|
table_ref,
|
|
&original.name,
|
|
&datatype,
|
|
db_type,
|
|
default_constraint_name,
|
|
));
|
|
} else if let Some(s) = def_val.as_str() {
|
|
let formatted = format_default_value(s, &original.datatype, db_type);
|
|
queries.push(render_add_default_value(
|
|
table_ref,
|
|
&original.name,
|
|
&formatted,
|
|
db_type,
|
|
));
|
|
}
|
|
}
|
|
|
|
// Nullable changes
|
|
if let Some(nullable) = changes.get("nullable").and_then(|v| v.as_bool()) {
|
|
queries.push(render_alter_nullable(
|
|
table_ref,
|
|
&original.name,
|
|
nullable,
|
|
&datatype,
|
|
db_type,
|
|
));
|
|
}
|
|
|
|
// Rename
|
|
if let Some(new_name) = changes.get("name").and_then(|v| v.as_str()) {
|
|
queries.push(render_rename_column(
|
|
table_ref,
|
|
&original.name,
|
|
new_name,
|
|
db_type,
|
|
));
|
|
}
|
|
|
|
Ok(queries)
|
|
}
|
|
|
|
// SAFETY: datatype is not escaped — this SQL runs against the user's own external database
|
|
// (DB Studio), not Windmill's internal DB. Users can already run arbitrary SQL.
|
|
fn render_alter_datatype(table_ref: &str, col: &str, datatype: &str, db_type: DbType) -> String {
|
|
let qc = qi(col, db_type);
|
|
match db_type {
|
|
DbType::Postgresql | DbType::Duckdb => {
|
|
format!(
|
|
"ALTER TABLE {} ALTER COLUMN {} TYPE {};",
|
|
table_ref, qc, datatype
|
|
)
|
|
}
|
|
DbType::MsSqlServer => {
|
|
format!(
|
|
"ALTER TABLE {} ALTER COLUMN {} {};",
|
|
table_ref, qc, datatype
|
|
)
|
|
}
|
|
DbType::Mysql => {
|
|
format!(
|
|
"ALTER TABLE {} MODIFY COLUMN {} {};",
|
|
table_ref, qc, datatype
|
|
)
|
|
}
|
|
DbType::Snowflake | DbType::Bigquery => {
|
|
format!(
|
|
"ALTER TABLE {} ALTER COLUMN {} SET DATA TYPE {};",
|
|
table_ref, qc, datatype
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_drop_default_value(
|
|
table_ref: &str,
|
|
col: &str,
|
|
_datatype: &str,
|
|
db_type: DbType,
|
|
constraint_name: Option<&str>,
|
|
) -> String {
|
|
let qc = qi(col, db_type);
|
|
match db_type {
|
|
DbType::MsSqlServer => format!(
|
|
"ALTER TABLE {} DROP CONSTRAINT {};",
|
|
table_ref,
|
|
render_db_quoted_identifier(constraint_name.unwrap_or(""), db_type)
|
|
),
|
|
_ => format!(
|
|
"ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
|
|
table_ref, qc
|
|
),
|
|
}
|
|
}
|
|
|
|
fn render_add_default_value(
|
|
table_ref: &str,
|
|
col: &str,
|
|
default_value: &str,
|
|
db_type: DbType,
|
|
) -> String {
|
|
let qc = qi(col, db_type);
|
|
match db_type {
|
|
DbType::MsSqlServer => format!(
|
|
"ALTER TABLE {} ADD CONSTRAINT DF_{}_{} DEFAULT {} FOR {};",
|
|
table_ref,
|
|
table_ref
|
|
.replace('.', "_")
|
|
.replace('"', "")
|
|
.replace('[', "")
|
|
.replace(']', ""),
|
|
col,
|
|
default_value,
|
|
qc
|
|
),
|
|
_ => format!(
|
|
"ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
|
|
table_ref, qc, default_value
|
|
),
|
|
}
|
|
}
|
|
|
|
fn render_alter_nullable(
|
|
table_ref: &str,
|
|
col: &str,
|
|
nullable: bool,
|
|
datatype: &str,
|
|
db_type: DbType,
|
|
) -> String {
|
|
let qc = qi(col, db_type);
|
|
match db_type {
|
|
DbType::Postgresql | DbType::Duckdb | DbType::Snowflake | DbType::Bigquery => {
|
|
let action = if nullable { "DROP" } else { "SET" };
|
|
format!(
|
|
"ALTER TABLE {} ALTER COLUMN {} {} NOT NULL;",
|
|
table_ref, qc, action
|
|
)
|
|
}
|
|
DbType::MsSqlServer => {
|
|
let null_str = if nullable { "NULL" } else { "NOT NULL" };
|
|
format!(
|
|
"ALTER TABLE {} ALTER COLUMN {} {} {};",
|
|
table_ref, qc, datatype, null_str
|
|
)
|
|
}
|
|
DbType::Mysql => {
|
|
let null_str = if nullable { "NULL" } else { "NOT NULL" };
|
|
format!(
|
|
"ALTER TABLE {} MODIFY COLUMN {} {} {};",
|
|
table_ref, qc, datatype, null_str
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_rename_column(
|
|
table_ref: &str,
|
|
old_name: &str,
|
|
new_name: &str,
|
|
db_type: DbType,
|
|
) -> String {
|
|
match db_type {
|
|
DbType::MsSqlServer => {
|
|
format!(
|
|
"EXEC sp_rename '{}.{}', '{}', 'COLUMN';",
|
|
escape_sql_literal(table_ref),
|
|
escape_sql_literal(old_name),
|
|
escape_sql_literal(new_name)
|
|
)
|
|
}
|
|
_ => format!(
|
|
"ALTER TABLE {} RENAME COLUMN {} TO {};",
|
|
table_ref,
|
|
qi(old_name, db_type),
|
|
qi(new_name, db_type)
|
|
),
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Metadata queries
|
|
// ===========================================================================
|
|
|
|
fn expand_load_table_metadata(json_str: &str, db_type: DbType) -> Result<ExpandedQuery, String> {
|
|
let p: LoadTableMetadataPayload = serde_json::from_str(json_str)
|
|
.map_err(|e| format!("Invalid LOAD_TABLE_METADATA payload: {}", e))?;
|
|
|
|
// BigQuery all-tables requires a Bun (JS) script, not SQL
|
|
if db_type == DbType::Bigquery && p.table.is_none() {
|
|
let code = make_bigquery_all_tables_bun_script();
|
|
return Ok(ExpandedQuery::with_language(code, ScriptLang::Bun));
|
|
}
|
|
|
|
let query =
|
|
make_load_table_metadata_query(db_type, p.table.as_deref(), p.database_name.as_deref())?;
|
|
Ok(ExpandedQuery::sql(maybe_wrap_ducklake(
|
|
query,
|
|
p.ducklake.as_deref(),
|
|
)))
|
|
}
|
|
|
|
fn make_load_table_metadata_query(
|
|
db_type: DbType,
|
|
table: Option<&str>,
|
|
database_name: Option<&str>,
|
|
) -> Result<String, String> {
|
|
match db_type {
|
|
DbType::Duckdb => {
|
|
// For ducklake, the ducklake ATTACH is handled by the ducklake wrapper, so the
|
|
// ducklake catalog is the current database. information_schema spans every attached
|
|
// catalog, so we always scope to current_database() to stay within the ducklake.
|
|
let extra_col = if table.is_none() {
|
|
",\n TABLE_SCHEMA as schema_name"
|
|
} else {
|
|
""
|
|
};
|
|
let mut q = format!(
|
|
"SELECT
|
|
COLUMN_NAME as field,
|
|
DATA_TYPE as DataType,
|
|
COLUMN_DEFAULT as DefaultValue,
|
|
false as IsPrimaryKey,
|
|
false as IsIdentity,
|
|
CASE WHEN IS_NULLABLE = true THEN 'YES' ELSE 'NO' END as IsNullable,
|
|
false as IsEnum,
|
|
TABLE_NAME as table_name{}
|
|
FROM information_schema.columns c
|
|
WHERE table_catalog = current_database()",
|
|
extra_col
|
|
);
|
|
if let Some(t) = table {
|
|
let parts: Vec<&str> = t.split('.').collect();
|
|
let tname = parts[parts.len() - 1];
|
|
let schema = if parts.len() > 1 { parts[0] } else { "main" };
|
|
q.push_str(&format!(
|
|
" AND TABLE_NAME = '{}' AND TABLE_SCHEMA = '{}'",
|
|
escape_sql_literal(tname),
|
|
escape_sql_literal(schema)
|
|
));
|
|
}
|
|
Ok(q)
|
|
}
|
|
DbType::Mysql => {
|
|
let explicit_db = database_name.filter(|s| !s.is_empty());
|
|
let table_filter = if let Some(t) = table {
|
|
let parts: Vec<&str> = t.split('.').collect();
|
|
let tname = parts[parts.len() - 1];
|
|
let schema_sql = if parts.len() > 1 {
|
|
format!("'{}'", escape_sql_literal(parts[0]))
|
|
} else {
|
|
explicit_db
|
|
.map(|dn| format!("'{}'", escape_sql_literal(dn)))
|
|
.unwrap_or_else(|| "DATABASE()".to_string())
|
|
};
|
|
format!(
|
|
"\nWHERE\n TABLE_NAME = '{}' AND TABLE_SCHEMA = {}",
|
|
escape_sql_literal(tname),
|
|
schema_sql
|
|
)
|
|
} else {
|
|
let schema_predicate = explicit_db
|
|
.map(|dn| format!("TABLE_SCHEMA = '{}'", escape_sql_literal(dn)))
|
|
.unwrap_or_else(|| "TABLE_SCHEMA = DATABASE()".to_string());
|
|
format!(
|
|
"\nWHERE\n {}\n AND TABLE_SCHEMA NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys', '_vt')",
|
|
schema_predicate
|
|
)
|
|
};
|
|
let extra_col = if table.is_none() {
|
|
",\n TABLE_NAME as table_name"
|
|
} else {
|
|
""
|
|
};
|
|
Ok(format!(
|
|
"SELECT \n COLUMN_NAME as field,\n COLUMN_TYPE as DataType,\n COLUMN_DEFAULT as DefaultValue,\n CASE WHEN COLUMN_KEY = 'PRI' THEN 1 ELSE 0 END as IsPrimaryKey,\n CASE WHEN EXTRA like '%auto_increment%' THEN 'YES' ELSE 'NO' END as IsIdentity,\n CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable,\n CASE WHEN DATA_TYPE = 'enum' THEN true ELSE false END as IsEnum{}\nFROM \n INFORMATION_SCHEMA.COLUMNS{}\nORDER BY\n TABLE_NAME,\n ORDINAL_POSITION;",
|
|
extra_col, table_filter
|
|
))
|
|
}
|
|
DbType::Postgresql => {
|
|
let (where_clause, extra_cols, joins, order) = if let Some(t) = table {
|
|
let parts: Vec<&str> = t.split('.').collect();
|
|
let tname = parts[parts.len() - 1];
|
|
let schema = if parts.len() > 1 { parts[0] } else { "public" };
|
|
(
|
|
format!(
|
|
"\nWHERE a.attrelid = (SELECT c.oid FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid WHERE relname = '{}' AND ns.nspname = '{}')\n AND a.attnum > 0 AND NOT a.attisdropped",
|
|
escape_sql_literal(tname),
|
|
escape_sql_literal(schema)
|
|
),
|
|
String::new(),
|
|
String::new(),
|
|
"a.attnum".to_string(),
|
|
)
|
|
} else {
|
|
(
|
|
// pg_catalog is readable by everyone: without the privilege check this lists
|
|
// tables of schemas the connection's role cannot even enter.
|
|
"\nWHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped\n AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'\n AND has_schema_privilege(ns.oid, 'USAGE')".to_string(),
|
|
",\n ns.nspname AS schema_name,\n c.relname AS table_name".to_string(),
|
|
"\nJOIN pg_catalog.pg_class c ON a.attrelid = c.oid\nJOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid".to_string(),
|
|
"ns.nspname, c.relname, a.attnum".to_string(),
|
|
)
|
|
};
|
|
Ok(format!(
|
|
"SELECT \n a.attname as field,\n pg_catalog.format_type(a.atttypid, a.atttypmod) as DataType,\n (SELECT substring(pg_catalog.pg_get_expr(d.adbin, d.adrelid, true) for 128)\n FROM pg_catalog.pg_attrdef d\n WHERE d.adrelid = a.attrelid AND d.adnum = a.attnum AND a.atthasdef) as DefaultValue,\n (SELECT CASE WHEN i.indisprimary THEN true ELSE 'NO' END\n FROM pg_catalog.pg_class tbl, pg_catalog.pg_class idx, pg_catalog.pg_index i, pg_catalog.pg_attribute att\n WHERE tbl.oid = a.attrelid AND idx.oid = i.indexrelid AND att.attrelid = tbl.oid\n AND i.indrelid = tbl.oid AND att.attnum = any(i.indkey) AND att.attname = a.attname LIMIT 1) as IsPrimaryKey,\n CASE a.attidentity\n WHEN 'd' THEN 'By Default'\n WHEN 'a' THEN 'Always'\n ELSE 'No'\n END as IsIdentity,\n CASE a.attnotnull\n WHEN false THEN 'YES'\n ELSE 'NO'\n END as IsNullable,\n (SELECT true\n FROM pg_catalog.pg_enum e\n WHERE e.enumtypid = a.atttypid FETCH FIRST ROW ONLY) as IsEnum{}\nFROM pg_catalog.pg_attribute a{}{}\nORDER BY {};",
|
|
extra_cols, joins, where_clause, order
|
|
))
|
|
}
|
|
DbType::MsSqlServer => {
|
|
let table_filter = if let Some(t) = table {
|
|
format!("\nWHERE\n c.TABLE_NAME = '{}'", escape_sql_literal(t))
|
|
} else {
|
|
"\nWHERE\n c.TABLE_SCHEMA != 'sys'".to_string()
|
|
};
|
|
let extra_col = if table.is_none() {
|
|
",\n c.TABLE_NAME as table_name"
|
|
} else {
|
|
""
|
|
};
|
|
Ok(format!(
|
|
"SELECT\n c.COLUMN_NAME as field,\n c.DATA_TYPE as DataType,\n c.COLUMN_DEFAULT as DefaultValue,\n CASE WHEN COLUMNPROPERTY(OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), c.COLUMN_NAME, 'IsIdentity') = 1 THEN 'By Default' ELSE 'No' END as IsIdentity,\n CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END as IsPrimaryKey,\n CASE WHEN c.IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable,\n CASE WHEN c.DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum,\n dc.name as default_constraint_name{}\nFROM\n INFORMATION_SCHEMA.COLUMNS c\n LEFT JOIN (\n SELECT\n ku.TABLE_SCHEMA,\n ku.TABLE_NAME,\n ku.COLUMN_NAME\n FROM\n INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc\n INNER JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku\n ON tc.CONSTRAINT_TYPE = 'PRIMARY KEY'\n AND tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME\n AND tc.TABLE_SCHEMA = ku.TABLE_SCHEMA\n AND tc.TABLE_NAME = ku.TABLE_NAME\n ) pk ON c.TABLE_SCHEMA = pk.TABLE_SCHEMA\n AND c.TABLE_NAME = pk.TABLE_NAME\n AND c.COLUMN_NAME = pk.COLUMN_NAME\n LEFT JOIN sys.default_constraints dc\n ON dc.parent_object_id = OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME)\n AND dc.parent_column_id = COLUMNPROPERTY(OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), c.COLUMN_NAME, 'ColumnId'){}\nORDER BY\n c.ORDINAL_POSITION;",
|
|
extra_col, table_filter
|
|
))
|
|
}
|
|
DbType::Snowflake => {
|
|
let table_filter = if let Some(t) = table {
|
|
let parts: Vec<&str> = t.split('.').collect();
|
|
let tname = parts[parts.len() - 1];
|
|
let schema = if parts.len() > 1 { parts[0] } else { "PUBLIC" };
|
|
format!(
|
|
"\nwhere table_name = '{}' and table_schema = '{}'",
|
|
escape_sql_literal(tname),
|
|
escape_sql_literal(schema)
|
|
)
|
|
} else {
|
|
"\nwhere table_schema <> 'INFORMATION_SCHEMA'\n".to_string()
|
|
};
|
|
let extra_col = if table.is_none() {
|
|
",\n table_name as table_name,\n table_schema as schema_name"
|
|
} else {
|
|
""
|
|
};
|
|
Ok(format!(
|
|
"select COLUMN_NAME as field,\n DATA_TYPE as DataType,\n COLUMN_DEFAULT as DefaultValue,\n CASE WHEN COLUMN_DEFAULT like 'AUTOINCREMENT%' THEN 'By Default' ELSE 'No' END as IsIdentity,\n 0 as IsPrimaryKey,\n CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable,\n CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum{}\nfrom information_schema.columns{}\norder by ORDINAL_POSITION;",
|
|
extra_col, table_filter
|
|
))
|
|
}
|
|
DbType::Bigquery => {
|
|
let t = table.ok_or("BigQuery table must be in dataset.table format")?;
|
|
let parts: Vec<&str> = t.splitn(2, '.').collect();
|
|
if parts.len() < 2 {
|
|
return Err("BigQuery table must be in dataset.table format".to_string());
|
|
}
|
|
let dataset = parts[0];
|
|
let tname = parts[1];
|
|
let qd = qi(dataset, DbType::Bigquery);
|
|
Ok(format!(
|
|
"SELECT \n c.COLUMN_NAME as field,\n DATA_TYPE as DataType,\n CASE WHEN COLUMN_DEFAULT = 'NULL' THEN '' ELSE COLUMN_DEFAULT END as DefaultValue,\n CASE WHEN constraint_name is not null THEN true ELSE false END as IsPrimaryKey,\n 'No' as IsIdentity,\n IS_NULLABLE as IsNullable,\n false as IsEnum\nFROM\n {}.INFORMATION_SCHEMA.COLUMNS c\n LEFT JOIN\n {}.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p\n on c.table_name = p.table_name AND c.column_name = p.COLUMN_NAME\nWHERE \n c.TABLE_NAME = '{}'\norder by c.ORDINAL_POSITION;",
|
|
qd, qd, escape_sql_literal(tname)
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// BigQuery all-tables metadata requires querying each dataset's INFORMATION_SCHEMA,
|
|
/// which can only be done via the BigQuery JS SDK. This generates a Bun script.
|
|
fn make_bigquery_all_tables_bun_script() -> String {
|
|
r#"import { BigQuery } from '@google-cloud/bigquery@7.5.0';
|
|
export async function main(database: bigquery) {
|
|
const bq = new BigQuery({
|
|
credentials: database
|
|
})
|
|
const [datasets] = await bq.getDatasets();
|
|
if (!datasets) return {}
|
|
const schema = {} as any
|
|
let queries = datasets.map(dataset => `
|
|
(SELECT
|
|
c.COLUMN_NAME as field,
|
|
'${dataset.id}' as schema_name,
|
|
c.TABLE_NAME as table_name,
|
|
DATA_TYPE as DataType,
|
|
CASE WHEN COLUMN_DEFAULT = 'NULL' THEN '' ELSE COLUMN_DEFAULT END as DefaultValue,
|
|
CASE WHEN constraint_name is not null THEN true ELSE false END as IsPrimaryKey,
|
|
'No' as IsIdentity,
|
|
IS_NULLABLE as IsNullable,
|
|
false as IsEnum
|
|
FROM
|
|
\`${dataset.id}\`.INFORMATION_SCHEMA.COLUMNS c
|
|
LEFT JOIN
|
|
\`${dataset.id}\`.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p
|
|
on c.table_name = p.table_name AND c.column_name = p.COLUMN_NAME
|
|
ORDER BY c.ORDINAL_POSITION)`
|
|
)
|
|
let query = queries.join('\nUNION ALL \n')
|
|
const [rows] = await bq.query(query)
|
|
return rows
|
|
}"#
|
|
.to_string()
|
|
}
|
|
|
|
// --- Relational keys queries ---
|
|
|
|
fn expand_foreign_keys(json_str: &str, db_type: DbType) -> Result<String, String> {
|
|
let p: ForeignKeysPayload = serde_json::from_str(json_str)
|
|
.map_err(|e| format!("Invalid FOREIGN_KEYS payload: {}", e))?;
|
|
let query = make_foreign_keys_query(db_type, &p.table, p.schema.as_deref())?;
|
|
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
|
|
}
|
|
|
|
fn expand_primary_key_constraint(json_str: &str, db_type: DbType) -> Result<String, String> {
|
|
let p: PrimaryKeyConstraintPayload = serde_json::from_str(json_str)
|
|
.map_err(|e| format!("Invalid PRIMARY_KEY_CONSTRAINT payload: {}", e))?;
|
|
let query = make_primary_key_constraint_query(db_type, &p.table, p.schema.as_deref())?;
|
|
Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref()))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SnowflakePrimaryKeysPayload {
|
|
table: Option<String>,
|
|
}
|
|
|
|
fn expand_snowflake_primary_keys(json_str: &str) -> Result<String, String> {
|
|
let p: SnowflakePrimaryKeysPayload = serde_json::from_str(json_str)
|
|
.map_err(|e| format!("Invalid SNOWFLAKE_PRIMARY_KEYS payload: {}", e))?;
|
|
match p.table {
|
|
Some(table) => Ok(format!(
|
|
"SHOW PRIMARY KEYS IN TABLE {}",
|
|
quote_table_name(&table, DbType::Snowflake)
|
|
)),
|
|
None => Ok("SHOW PRIMARY KEYS IN ACCOUNT".to_string()),
|
|
}
|
|
}
|
|
|
|
fn make_foreign_keys_query(
|
|
db_type: DbType,
|
|
table: &str,
|
|
default_schema: Option<&str>,
|
|
) -> Result<String, String> {
|
|
let parts: Vec<&str> = table.split('.').collect();
|
|
let table_name = parts[parts.len() - 1];
|
|
let schema_name = if parts.len() > 1 {
|
|
parts[0]
|
|
} else {
|
|
default_schema.unwrap_or(match db_type {
|
|
DbType::Postgresql => "public",
|
|
DbType::Duckdb => "main",
|
|
DbType::Mysql => "",
|
|
DbType::MsSqlServer => "dbo",
|
|
DbType::Snowflake => "PUBLIC",
|
|
DbType::Bigquery => return Err("BigQuery requires a schema for FK queries".to_string()),
|
|
})
|
|
};
|
|
|
|
let tn = escape_sql_literal(table_name);
|
|
let sn = escape_sql_literal(schema_name);
|
|
|
|
match db_type {
|
|
DbType::Postgresql => Ok(format!(
|
|
"SELECT\n tc.constraint_name as fk_constraint_name,\n kcu.column_name as source_column,\n ccu.table_schema || '.' || ccu.table_name as target_table,\n ccu.column_name as target_column,\n COALESCE(rc.delete_rule, 'NO ACTION') as on_delete,\n COALESCE(rc.update_rule, 'NO ACTION') as on_update\nFROM\n information_schema.table_constraints AS tc\n JOIN information_schema.key_column_usage AS kcu\n ON tc.constraint_name = kcu.constraint_name\n AND tc.table_schema = kcu.table_schema\n JOIN information_schema.constraint_column_usage AS ccu\n ON ccu.constraint_name = tc.constraint_name\n AND ccu.table_schema = tc.table_schema\n LEFT JOIN information_schema.referential_constraints AS rc\n ON rc.constraint_name = tc.constraint_name\n AND rc.constraint_schema = tc.table_schema\nWHERE\n tc.constraint_type = 'FOREIGN KEY'\n AND tc.table_name = '{}'\n AND tc.table_schema = '{}'\nORDER BY\n tc.constraint_name, kcu.ordinal_position;",
|
|
tn, sn
|
|
)),
|
|
DbType::Mysql => {
|
|
let where_schema = if schema_name.is_empty() {
|
|
"AND kcu.TABLE_SCHEMA = DATABASE()".to_string()
|
|
} else {
|
|
format!("AND kcu.TABLE_SCHEMA = '{}'", sn)
|
|
};
|
|
Ok(format!(
|
|
"SELECT\n kcu.CONSTRAINT_NAME as fk_constraint_name,\n kcu.COLUMN_NAME as source_column,\n CONCAT(kcu.REFERENCED_TABLE_SCHEMA, '.', kcu.REFERENCED_TABLE_NAME) as target_table,\n kcu.REFERENCED_COLUMN_NAME as target_column,\n COALESCE(rc.DELETE_RULE, 'NO ACTION') as on_delete,\n COALESCE(rc.UPDATE_RULE, 'NO ACTION') as on_update\nFROM\n INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu\n JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc\n ON kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME\n AND kcu.TABLE_SCHEMA = rc.CONSTRAINT_SCHEMA\nWHERE\n kcu.TABLE_NAME = '{}'\n {}\n AND kcu.REFERENCED_TABLE_NAME IS NOT NULL\nORDER BY\n kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION;",
|
|
tn, where_schema
|
|
))
|
|
}
|
|
DbType::MsSqlServer => Ok(format!(
|
|
"SELECT\n fk.name as fk_constraint_name,\n COL_NAME(fkc.parent_object_id, fkc.parent_column_id) as source_column,\n OBJECT_SCHEMA_NAME(fkc.referenced_object_id) + '.' + OBJECT_NAME(fkc.referenced_object_id) as target_table,\n COL_NAME(fkc.referenced_object_id, fkc.referenced_column_id) as target_column,\n CASE fk.delete_referential_action\n WHEN 0 THEN 'NO ACTION'\n WHEN 1 THEN 'CASCADE'\n WHEN 2 THEN 'SET NULL'\n WHEN 3 THEN 'SET DEFAULT'\n END as on_delete,\n CASE fk.update_referential_action\n WHEN 0 THEN 'NO ACTION'\n WHEN 1 THEN 'CASCADE'\n WHEN 2 THEN 'SET NULL'\n WHEN 3 THEN 'SET DEFAULT'\n END as on_update\nFROM\n sys.foreign_keys fk\n INNER JOIN sys.foreign_key_columns fkc\n ON fk.object_id = fkc.constraint_object_id\n INNER JOIN sys.tables t\n ON fk.parent_object_id = t.object_id\n INNER JOIN sys.schemas s\n ON t.schema_id = s.schema_id\nWHERE\n t.name = '{}'\n AND s.name = '{}'\nORDER BY\n fk.name, fkc.constraint_column_id;",
|
|
tn, sn
|
|
)),
|
|
DbType::Snowflake => Ok(format!(
|
|
"SHOW IMPORTED KEYS IN TABLE {}.{}",
|
|
qi(schema_name, db_type),
|
|
qi(table_name, db_type)
|
|
)),
|
|
DbType::Bigquery => Ok(format!(
|
|
"SELECT\n tc.constraint_name as fk_constraint_name,\n kcu.column_name as source_column,\n ccu.table_name as target_table,\n ccu.column_name as target_column,\n 'NO ACTION' as on_delete,\n 'NO ACTION' as on_update\nFROM\n `{}.INFORMATION_SCHEMA.TABLE_CONSTRAINTS` tc\n JOIN `{}.INFORMATION_SCHEMA.KEY_COLUMN_USAGE` kcu\n ON tc.constraint_name = kcu.constraint_name\n JOIN `{}.INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE` ccu\n ON tc.constraint_name = ccu.constraint_name\nWHERE\n tc.constraint_type = 'FOREIGN KEY'\n AND tc.table_name = '{}'\nORDER BY\n tc.constraint_name, kcu.ordinal_position;",
|
|
schema_name, schema_name, schema_name, tn
|
|
)),
|
|
DbType::Duckdb => Ok(format!(
|
|
"SELECT\n fk_constraint.constraint_name as fk_constraint_name,\n kcu.column_name as source_column,\n ccu.table_schema || '.' || ccu.table_name as target_table,\n ccu.column_name as target_column,\n 'NO ACTION' as on_delete,\n 'NO ACTION' as on_update\nFROM\n information_schema.table_constraints fk_constraint\n JOIN information_schema.key_column_usage kcu\n ON fk_constraint.constraint_name = kcu.constraint_name\n AND fk_constraint.constraint_schema = kcu.constraint_schema\n JOIN information_schema.constraint_column_usage ccu\n ON fk_constraint.constraint_name = ccu.constraint_name\n AND fk_constraint.constraint_schema = ccu.constraint_schema\nWHERE\n fk_constraint.constraint_type = 'FOREIGN KEY'\n AND fk_constraint.table_name = '{}'\n AND fk_constraint.table_schema = '{}'\nORDER BY\n fk_constraint.constraint_name, kcu.ordinal_position;",
|
|
tn, sn
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn make_primary_key_constraint_query(
|
|
db_type: DbType,
|
|
table: &str,
|
|
default_schema: Option<&str>,
|
|
) -> Result<String, String> {
|
|
let parts: Vec<&str> = table.split('.').collect();
|
|
let table_name = parts[parts.len() - 1];
|
|
let schema_name = if parts.len() > 1 {
|
|
parts[0]
|
|
} else {
|
|
default_schema.unwrap_or(match db_type {
|
|
DbType::Postgresql => "public",
|
|
DbType::Duckdb => "main",
|
|
DbType::Mysql => "",
|
|
DbType::MsSqlServer => "dbo",
|
|
DbType::Snowflake => "PUBLIC",
|
|
DbType::Bigquery => return Err("BigQuery requires a schema for PK queries".to_string()),
|
|
})
|
|
};
|
|
|
|
let tn = escape_sql_literal(table_name);
|
|
let sn = escape_sql_literal(schema_name);
|
|
|
|
match db_type {
|
|
DbType::Postgresql | DbType::Duckdb => Ok(format!(
|
|
"SELECT\n tc.constraint_name\nFROM\n information_schema.table_constraints AS tc\nWHERE\n tc.constraint_type = 'PRIMARY KEY'\n AND tc.table_name = '{}'\n AND tc.table_schema = '{}'\nLIMIT 1;",
|
|
tn, sn
|
|
)),
|
|
DbType::Mysql => {
|
|
let where_schema = if schema_name.is_empty() {
|
|
"AND tc.TABLE_SCHEMA = DATABASE()".to_string()
|
|
} else {
|
|
format!("AND tc.TABLE_SCHEMA = '{}'", sn)
|
|
};
|
|
Ok(format!(
|
|
"SELECT\n tc.CONSTRAINT_NAME as constraint_name\nFROM\n INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc\nWHERE\n tc.CONSTRAINT_TYPE = 'PRIMARY KEY'\n AND tc.TABLE_NAME = '{}'\n {}\nLIMIT 1;",
|
|
tn, where_schema
|
|
))
|
|
}
|
|
DbType::MsSqlServer => Ok(format!(
|
|
"SELECT\n kc.name as constraint_name\nFROM\n sys.key_constraints kc\n INNER JOIN sys.tables t\n ON kc.parent_object_id = t.object_id\n INNER JOIN sys.schemas s\n ON t.schema_id = s.schema_id\nWHERE\n kc.type = 'PK'\n AND t.name = '{}'\n AND s.name = '{}';",
|
|
tn, sn
|
|
)),
|
|
DbType::Snowflake => Ok(format!(
|
|
"SELECT\n constraint_name\nFROM\n information_schema.table_constraints\nWHERE\n constraint_type = 'PRIMARY KEY'\n AND table_name = '{}'\n AND table_schema = '{}'\nLIMIT 1;",
|
|
tn, sn
|
|
)),
|
|
DbType::Bigquery => Ok(format!(
|
|
"SELECT\n constraint_name\nFROM\n `{}.INFORMATION_SCHEMA.TABLE_CONSTRAINTS`\nWHERE\n constraint_type = 'PRIMARY KEY'\n AND table_name = '{}'\nLIMIT 1;",
|
|
schema_name, tn
|
|
)),
|
|
}
|
|
}
|
|
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn col(field: &str, datatype: &str) -> ColumnDef {
|
|
ColumnDef::simple(field, datatype)
|
|
}
|
|
|
|
fn simple_col(field: &str, datatype: &str) -> SimpleColumn {
|
|
SimpleColumn { field: field.to_string(), datatype: datatype.to_string() }
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// build_parameters
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_build_parameters_postgresql() {
|
|
let cols = vec![simple_col("limit", "int"), simple_col("offset", "int")];
|
|
let result = build_parameters(&cols, DbType::Postgresql);
|
|
assert_eq!(result, "-- $1 limit (int)\n-- $2 offset (int)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_parameters_mysql() {
|
|
let cols = vec![
|
|
simple_col("limit", "int"),
|
|
simple_col("name", "varchar(255)"),
|
|
];
|
|
let result = build_parameters(&cols, DbType::Mysql);
|
|
assert_eq!(result, "-- :limit (int)\n-- :name (varchar)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_parameters_mssql() {
|
|
let cols = vec![
|
|
simple_col("limit", "int"),
|
|
simple_col("name", "nvarchar(50)"),
|
|
];
|
|
let result = build_parameters(&cols, DbType::MsSqlServer);
|
|
assert_eq!(result, "-- @p1 limit (int)\n-- @p2 name (nvarchar)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_parameters_snowflake() {
|
|
let cols = vec![
|
|
simple_col("quicksearch", "text"),
|
|
simple_col("order_by", "text"),
|
|
];
|
|
let result = build_parameters(&cols, DbType::Snowflake);
|
|
assert_eq!(result, "-- ? quicksearch (text)\n-- ? order_by (text)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_parameters_bigquery() {
|
|
let cols = vec![
|
|
simple_col("quicksearch", "string"),
|
|
simple_col("order_by", "string"),
|
|
];
|
|
let result = build_parameters(&cols, DbType::Bigquery);
|
|
assert_eq!(result, "-- @quicksearch (string)\n-- @order_by (string)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_parameters_duckdb() {
|
|
let cols = vec![simple_col("limit", "int"), simple_col("offset", "int")];
|
|
let result = build_parameters(&cols, DbType::Duckdb);
|
|
assert_eq!(result, "-- $limit (int)\n-- $offset (int)");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// render_db_quoted_identifier
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_quoted_identifiers() {
|
|
assert_eq!(
|
|
render_db_quoted_identifier("col", DbType::Postgresql),
|
|
"\"col\""
|
|
);
|
|
assert_eq!(render_db_quoted_identifier("col", DbType::Mysql), "`col`");
|
|
assert_eq!(
|
|
render_db_quoted_identifier("col", DbType::MsSqlServer),
|
|
"[col]"
|
|
);
|
|
assert_eq!(
|
|
render_db_quoted_identifier("col", DbType::Snowflake),
|
|
"\"col\""
|
|
);
|
|
assert_eq!(
|
|
render_db_quoted_identifier("col", DbType::Bigquery),
|
|
"`col`"
|
|
);
|
|
assert_eq!(
|
|
render_db_quoted_identifier("col", DbType::Duckdb),
|
|
"\"col\""
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// build_visible_field_list
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_build_visible_field_list_filters_ignored() {
|
|
let cols = vec![
|
|
col("id", "int4"),
|
|
{
|
|
let mut c = col("hidden", "text");
|
|
c.ignored = Some(true);
|
|
c
|
|
},
|
|
col("name", "text"),
|
|
];
|
|
let result = build_visible_field_list(&cols, DbType::Postgresql);
|
|
assert_eq!(result, vec!["\"id\"", "\"name\""]);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// SELECT - PostgreSQL
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_select_postgresql_basic() {
|
|
let cols = vec![col("id", "int4"), col("name", "text")];
|
|
let result =
|
|
make_select_query("my_table", &cols, None, DbType::Postgresql, None, None).unwrap();
|
|
|
|
assert!(result.starts_with(
|
|
"-- $1 limit (int)\n-- $2 offset (int)\n-- $3 quicksearch (text)\n-- $4 order_by (text)\n-- $5 is_desc (boolean)\n"
|
|
));
|
|
assert!(result.contains("SELECT \"id\"::text, \"name\"::text FROM \"my_table\"\n"));
|
|
assert!(result.contains("($3 = '' OR CONCAT(\"id\", \"name\") ILIKE '%' || $3 || '%')"));
|
|
assert!(result.contains("LIMIT $1 OFFSET $2"));
|
|
assert!(result.contains("$4 = 'id' AND $5 IS false THEN \"id\"::text"));
|
|
assert!(result.contains("$4 = 'name' AND $5 IS true THEN \"name\"::text END) DESC"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_select_postgresql_with_where() {
|
|
let cols = vec![col("id", "int4")];
|
|
let result = make_select_query(
|
|
"my_table",
|
|
&cols,
|
|
Some("status = 'active'"),
|
|
DbType::Postgresql,
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
assert!(result.contains(
|
|
"WHERE status = 'active' AND ($3 = '' OR CONCAT(\"id\") ILIKE '%' || $3 || '%')"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_select_postgresql_fix_pg_int_types() {
|
|
let cols = vec![col("id", "int4"), col("data", "json")];
|
|
let bf = BreakingFeatures { fix_pg_int_types: true };
|
|
let result =
|
|
make_select_query("my_table", &cols, None, DbType::Postgresql, None, Some(&bf))
|
|
.unwrap();
|
|
|
|
// json column should get text cast only (no number check)
|
|
assert!(result.contains("$4 = 'data' AND $5 IS false THEN \"data\"::text END)"));
|
|
// int column should have number check variants
|
|
assert!(result.contains("pg_typeof(\"id\")::text NOT IN"));
|
|
assert!(result.contains("pg_typeof(\"id\")::text IN"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_select_postgresql_fix_pg_array_type() {
|
|
let cols = vec![col("tags", "text[]")];
|
|
let bf = BreakingFeatures { fix_pg_int_types: true };
|
|
let result =
|
|
make_select_query("my_table", &cols, None, DbType::Postgresql, None, Some(&bf))
|
|
.unwrap();
|
|
|
|
// Array type should force text cast (no number check variants)
|
|
assert!(result.contains("$4 = 'tags' AND $5 IS false THEN \"tags\"::text END)"));
|
|
// Should NOT contain pg_typeof checks
|
|
assert!(!result.contains("pg_typeof(\"tags\")"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// SELECT - MySQL
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_select_mysql_basic() {
|
|
let cols = vec![col("id", "int"), col("name", "varchar")];
|
|
let result = make_select_query("my_table", &cols, None, DbType::Mysql, None, None).unwrap();
|
|
|
|
assert!(result.starts_with("-- :limit (int)\n-- :offset (int)\n-- :quicksearch (text)\n-- :order_by (text)\n-- :is_desc (boolean)\n"));
|
|
assert!(result.contains("SELECT `id`, `name` FROM `my_table`"));
|
|
assert!(result.contains("CONCAT_WS(' ', `id`, `name`) LIKE CONCAT('%', :quicksearch, '%')"));
|
|
assert!(result.contains("LIMIT :limit OFFSET :offset"));
|
|
assert!(result.contains(":order_by = 'id' AND :is_desc IS false THEN `id` END"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// SELECT - MS SQL Server
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_select_mssql_basic() {
|
|
let cols = vec![col("id", "int"), col("name", "nvarchar")];
|
|
let result =
|
|
make_select_query("my_table", &cols, None, DbType::MsSqlServer, None, None).unwrap();
|
|
|
|
assert!(result.contains("SELECT [id], [name] FROM [my_table]"));
|
|
assert!(result.contains("@p3 = '' OR CONCAT([id], [name]) LIKE '%' + @p3 + '%'"));
|
|
assert!(result.contains("OFFSET @p2 ROWS FETCH NEXT @p1 ROWS ONLY"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_select_mssql_unsortable_type() {
|
|
let cols = vec![col("id", "int"), col("body", "text")];
|
|
let result =
|
|
make_select_query("my_table", &cols, None, DbType::MsSqlServer, None, None).unwrap();
|
|
|
|
// text type should be excluded from ORDER BY
|
|
assert!(!result.contains("@p4 = 'body'"));
|
|
// text type should be excluded from search clause
|
|
assert!(!result.contains("CONCAT([id], [body])"));
|
|
assert!(result.contains("CONCAT([id])"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// SELECT - Snowflake
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_select_snowflake_basic() {
|
|
let cols = vec![col("id", "int"), col("name", "text")];
|
|
let result =
|
|
make_select_query("my_table", &cols, None, DbType::Snowflake, None, None).unwrap();
|
|
|
|
// Parameters: quicksearch (1 + N filtered columns), then 4 per column for order_by
|
|
assert!(result.contains("-- ? quicksearch (text)"));
|
|
assert!(result.contains("SELECT \"id\", \"name\" FROM \"my_table\""));
|
|
assert!(result.contains("LENGTH(?) = 0"));
|
|
assert!(result.contains("CONCAT(\"id\") ILIKE CONCAT('%', ?, '%')"));
|
|
assert!(result.contains("LIMIT 100 OFFSET 0"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_select_snowflake_custom_limit() {
|
|
let cols = vec![col("id", "int")];
|
|
let opts = SelectOptions { limit: Some(50), offset: Some(10), version: None };
|
|
let result = make_select_query(
|
|
"my_table",
|
|
&cols,
|
|
None,
|
|
DbType::Snowflake,
|
|
Some(&opts),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
assert!(result.contains("LIMIT 50 OFFSET 10"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// SELECT - BigQuery
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_select_bigquery_basic() {
|
|
let cols = vec![col("id", "INTEGER"), col("name", "STRING")];
|
|
let result =
|
|
make_select_query("my_table", &cols, None, DbType::Bigquery, None, None).unwrap();
|
|
|
|
assert!(result.starts_with("-- @limit (integer)\n-- @offset (integer)\n-- @quicksearch (string)\n-- @order_by (string)\n-- @is_desc (bool)\n"));
|
|
assert!(result.contains("SELECT `id`, `name` FROM `my_table`"));
|
|
assert!(result.contains("CAST(`id` AS STRING)"));
|
|
assert!(result.contains("LIMIT @limit OFFSET @offset"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_select_bigquery_json_type() {
|
|
let cols = vec![col("id", "INTEGER"), col("data", "JSON")];
|
|
let result =
|
|
make_select_query("my_table", &cols, None, DbType::Bigquery, None, None).unwrap();
|
|
|
|
// JSON column should use TO_JSON_STRING in search and order
|
|
assert!(result.contains("TO_JSON_STRING(`data`)"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// SELECT - DuckDB
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_select_duckdb_basic() {
|
|
let cols = vec![col("id", "int"), col("name", "text")];
|
|
let result =
|
|
make_select_query("my_table", &cols, None, DbType::Duckdb, None, None).unwrap();
|
|
|
|
assert!(result.starts_with("-- $limit (int)\n-- $offset (int)\n-- $quicksearch (text)\n-- $order_by (text)\n-- $is_desc (boolean)\n"));
|
|
assert!(result.contains("SELECT \"id\", \"name\" FROM \"my_table\"\n"));
|
|
assert!(result.contains("CONCAT(\"id\", \"name\") ILIKE '%' || $quicksearch || '%'"));
|
|
assert!(result.contains("LIMIT $limit::INT OFFSET $offset::INT"));
|
|
}
|
|
|
|
/// Both the SELECT and the COUNT build the quicksearch predicate; fixing only
|
|
/// one leaves the grid rendering while the row count errors out.
|
|
#[test]
|
|
fn test_duckdb_quicksearch_casts_only_list_columns() {
|
|
let cols = vec![
|
|
col("id", "VARCHAR"),
|
|
col("tags", "VARCHAR[]"),
|
|
col("pos", "INTEGER[3]"),
|
|
col("meta", "STRUCT(a INTEGER)"),
|
|
];
|
|
|
|
let select =
|
|
make_select_query("my_table", &cols, None, DbType::Duckdb, None, None).unwrap();
|
|
assert!(
|
|
select.contains(
|
|
"CONCAT(\"id\", CAST(\"tags\" AS VARCHAR), CAST(\"pos\" AS VARCHAR), \"meta\") ILIKE"
|
|
),
|
|
"got:\n{}",
|
|
select
|
|
);
|
|
// The projection stays untouched: casting there would change the types
|
|
// the caller reads back.
|
|
assert!(select.contains("SELECT \"id\", \"tags\", \"pos\", \"meta\" FROM \"my_table\""));
|
|
|
|
let count = make_count_query(DbType::Duckdb, "my_table", None, &cols, None).unwrap();
|
|
assert!(
|
|
count.contains(
|
|
"CONCAT(' ', \"id\", CAST(\"tags\" AS VARCHAR), CAST(\"pos\" AS VARCHAR), \"meta\") LIKE"
|
|
),
|
|
"got:\n{}",
|
|
count
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// SELECT - error cases
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_select_empty_table_error() {
|
|
let cols = vec![col("id", "int")];
|
|
let result = make_select_query("", &cols, None, DbType::Postgresql, None, None);
|
|
assert!(result.is_err());
|
|
assert_eq!(result.unwrap_err(), "Table name is required");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// COUNT - PostgreSQL
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_count_postgresql_basic() {
|
|
let cols = vec![col("id", "int4"), col("name", "text")];
|
|
let result = make_count_query(DbType::Postgresql, "my_table", None, &cols, None).unwrap();
|
|
|
|
assert!(result.contains("-- $1 quicksearch (text)"));
|
|
assert!(result.contains("SELECT COUNT(*) as count FROM \"my_table\""));
|
|
assert!(result.contains("($1 = '' OR CONCAT(\"id\", \"name\") ILIKE '%' || $1 || '%')"));
|
|
// Should use WHERE not AND
|
|
assert!(result.contains(" WHERE "));
|
|
assert!(!result.contains("\"my_table\" AND "));
|
|
}
|
|
|
|
#[test]
|
|
fn test_count_postgresql_with_where() {
|
|
let cols = vec![col("id", "int4")];
|
|
let result = make_count_query(
|
|
DbType::Postgresql,
|
|
"my_table",
|
|
Some("status = 'active'"),
|
|
&cols,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(result.contains(
|
|
"WHERE status = 'active' AND ($1 = '' OR CONCAT(\"id\") ILIKE '%' || $1 || '%')"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_count_postgresql_no_visible_columns() {
|
|
let cols = vec![{
|
|
let mut c = col("id", "int4");
|
|
c.ignored = Some(true);
|
|
c
|
|
}];
|
|
let result = make_count_query(DbType::Postgresql, "my_table", None, &cols, None).unwrap();
|
|
assert!(result.contains("($1 = '' OR 1 = 1)"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// COUNT - MySQL
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_count_mysql_basic() {
|
|
let cols = vec![col("id", "int"), col("name", "varchar")];
|
|
let result = make_count_query(DbType::Mysql, "my_table", None, &cols, None).unwrap();
|
|
|
|
assert!(result.contains("-- :quicksearch (text)"));
|
|
assert!(result.contains("SELECT COUNT(*) as count FROM `my_table`"));
|
|
assert!(result.contains("CONCAT_WS(' ', `id`, `name`) LIKE CONCAT('%', :quicksearch, '%')"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// COUNT - MS SQL Server
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_count_mssql_basic() {
|
|
let cols = vec![col("id", "int"), col("name", "nvarchar")];
|
|
let result = make_count_query(DbType::MsSqlServer, "my_table", None, &cols, None).unwrap();
|
|
|
|
assert!(result.contains("SELECT COUNT(*) as count FROM [my_table]"));
|
|
assert!(result.contains("(@p1 = '' OR CONCAT([id], [name]) LIKE '%' + @p1 + '%')"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// COUNT - Snowflake
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_count_snowflake_basic() {
|
|
let cols = vec![col("id", "int"), col("name", "text")];
|
|
let result = make_count_query(DbType::Snowflake, "my_table", None, &cols, None).unwrap();
|
|
|
|
// Two quicksearch params for snowflake with visible columns
|
|
assert!(result.contains("-- ? quicksearch (text)\n-- ? quicksearch (text)"));
|
|
assert!(result.contains("SELECT COUNT(*) as count FROM \"my_table\""));
|
|
assert!(result.contains("(? = '' OR CONCAT(\"id\", \"name\") ILIKE '%' || ? || '%')"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_count_snowflake_no_visible_columns() {
|
|
let cols = vec![{
|
|
let mut c = col("id", "int");
|
|
c.ignored = Some(true);
|
|
c
|
|
}];
|
|
let result = make_count_query(DbType::Snowflake, "my_table", None, &cols, None).unwrap();
|
|
// One quicksearch param
|
|
let param_lines: Vec<&str> = result.lines().filter(|l| l.starts_with("-- ?")).collect();
|
|
assert_eq!(param_lines.len(), 1);
|
|
assert!(result.contains("(? = '' OR 1 = 1)"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// COUNT - BigQuery
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_count_bigquery_basic() {
|
|
let cols = vec![col("id", "INTEGER"), col("name", "STRING")];
|
|
let result = make_count_query(DbType::Bigquery, "my_table", None, &cols, None).unwrap();
|
|
|
|
assert!(result.contains("-- @quicksearch (string)"));
|
|
assert!(result.contains("SELECT COUNT(*) as count FROM `my_table`"));
|
|
assert!(result.contains("REGEXP_CONTAINS(CONCAT(`id`,`name`), '(?i)' || @quicksearch)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_count_bigquery_json_type() {
|
|
let cols = vec![col("id", "INTEGER"), col("data", "JSON")];
|
|
let result = make_count_query(DbType::Bigquery, "my_table", None, &cols, None).unwrap();
|
|
assert!(result.contains("TO_JSON_STRING(`data`)"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// COUNT - DuckDB
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_count_duckdb_basic() {
|
|
let cols = vec![col("id", "int"), col("name", "text")];
|
|
let result = make_count_query(DbType::Duckdb, "my_table", None, &cols, None).unwrap();
|
|
|
|
assert!(result.contains("-- $quicksearch (text)"));
|
|
assert!(result.contains("SELECT COUNT(*) as count FROM \"my_table\""));
|
|
assert!(
|
|
result.contains("CONCAT(' ', \"id\", \"name\") LIKE CONCAT('%', $quicksearch, '%')")
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// DuckLake time-travel (AT VERSION) + snapshot history
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_select_duckdb_time_travel() {
|
|
let cols = vec![col("id", "int"), col("name", "text")];
|
|
let opts = SelectOptions { limit: None, offset: None, version: Some(42) };
|
|
let result =
|
|
make_select_query("orders", &cols, None, DbType::Duckdb, Some(&opts), None).unwrap();
|
|
// Read is pinned to the catalog snapshot via AT (VERSION => n).
|
|
assert!(result.contains("FROM \"orders\" AT (VERSION => 42)\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_select_duckdb_no_version_unpinned() {
|
|
let cols = vec![col("id", "int")];
|
|
let result = make_select_query("orders", &cols, None, DbType::Duckdb, None, None).unwrap();
|
|
// Without a version the read targets the latest snapshot — no AT clause.
|
|
assert!(result.contains("FROM \"orders\"\n"));
|
|
assert!(!result.contains("AT (VERSION"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_count_duckdb_time_travel() {
|
|
let cols = vec![col("id", "int")];
|
|
let result = make_count_query(DbType::Duckdb, "orders", None, &cols, Some(7)).unwrap();
|
|
assert!(result.contains("FROM \"orders\" AT (VERSION => 7)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_version_ignored_for_non_duckdb() {
|
|
// AT (VERSION) is DuckLake-only; other dialects must never emit it even
|
|
// if a version is somehow passed through.
|
|
let cols = vec![col("id", "int4")];
|
|
let opts = SelectOptions { limit: None, offset: None, version: Some(5) };
|
|
let result =
|
|
make_select_query("orders", &cols, None, DbType::Postgresql, Some(&opts), None)
|
|
.unwrap();
|
|
assert!(!result.contains("AT (VERSION"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_ducklake_snapshots() {
|
|
let json = r#"{"ducklake": "analytics"}"#;
|
|
let result = expand_ducklake_snapshots(json, DbType::Duckdb).unwrap();
|
|
assert!(result.contains("ATTACH 'ducklake://analytics' AS dl;USE dl;"));
|
|
assert!(result.contains("ducklake_snapshots('dl')"));
|
|
assert!(result.contains("ORDER BY snapshot_id DESC"));
|
|
// Unscoped: no per-table existence filter.
|
|
assert!(!result.contains("tables_created"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_ducklake_snapshots_scoped_to_table() {
|
|
let json = r#"{"ducklake": "analytics", "table": "main.events_daily"}"#;
|
|
let result = expand_ducklake_snapshots(json, DbType::Duckdb).unwrap();
|
|
// Scoped to snapshots from the table's first creation onward.
|
|
assert!(result.contains("list_contains(changes.tables_created, 'main.events_daily')"));
|
|
assert!(result.contains("snapshot_id >= COALESCE"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_ducklake_snapshots_non_duckdb_errors() {
|
|
let json = r#"{"ducklake": "analytics"}"#;
|
|
assert!(expand_ducklake_snapshots(json, DbType::Postgresql).is_err());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// DELETE - all DB types
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_delete_postgresql() {
|
|
let cols = vec![col("id", "int4"), col("name", "text")];
|
|
let result = make_delete_query("my_table", &cols, DbType::Postgresql);
|
|
|
|
assert!(result.contains("-- $1 id (int4)\n-- $2 name (text)"));
|
|
assert!(result.contains("DELETE FROM \"my_table\""));
|
|
assert!(result.contains("($1 IS NULL AND \"id\" IS NULL OR \"id\" = $1)"));
|
|
assert!(result.contains("($2 IS NULL AND \"name\" IS NULL OR \"name\" = $2)"));
|
|
assert!(result.contains("RETURNING 1;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_mysql() {
|
|
let cols = vec![col("id", "int"), col("name", "varchar")];
|
|
let result = make_delete_query("my_table", &cols, DbType::Mysql);
|
|
|
|
assert!(result.contains("-- :id (int)\n-- :name (varchar)"));
|
|
assert!(result.contains("DELETE FROM `my_table`"));
|
|
assert!(result.contains("(:id IS NULL AND `id` IS NULL OR `id` = :id)"));
|
|
assert!(result.contains("(:name IS NULL AND `name` IS NULL OR `name` = :name)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_mssql() {
|
|
let cols = vec![col("id", "int"), col("name", "nvarchar")];
|
|
let result = make_delete_query("my_table", &cols, DbType::MsSqlServer);
|
|
|
|
assert!(result.contains("-- @p1 id (int)\n-- @p2 name (nvarchar)"));
|
|
assert!(result.contains("DELETE FROM [my_table]"));
|
|
assert!(result.contains("(@p1 IS NULL AND [id] IS NULL OR [id] = @p1)"));
|
|
assert!(result.contains("(@p2 IS NULL AND [name] IS NULL OR [name] = @p2)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_snowflake() {
|
|
let cols = vec![col("id", "int"), col("name", "text")];
|
|
let result = make_delete_query("my_table", &cols, DbType::Snowflake);
|
|
|
|
// Snowflake doubles columns
|
|
assert!(result.contains("-- ? id (int)\n-- ? id (int)\n-- ? name (text)\n-- ? name (text)"));
|
|
assert!(result.contains("DELETE FROM \"my_table\""));
|
|
assert!(result.contains("(? = 'null' AND \"id\" IS NULL OR \"id\" = ?)"));
|
|
assert!(result.contains("(? = 'null' AND \"name\" IS NULL OR \"name\" = ?)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_bigquery() {
|
|
let cols = vec![col("id", "INTEGER"), col("name", "STRING")];
|
|
let result = make_delete_query("my_table", &cols, DbType::Bigquery);
|
|
|
|
assert!(result.contains("-- @id (INTEGER)\n-- @name (STRING)"));
|
|
assert!(result.contains("DELETE FROM `my_table`"));
|
|
assert!(result.contains("(CAST(@id AS STRING) = 'null' AND `id` IS NULL OR `id` = @id)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_duckdb() {
|
|
let cols = vec![col("id", "int"), col("name", "text")];
|
|
let result = make_delete_query("my_table", &cols, DbType::Duckdb);
|
|
|
|
assert!(result.contains("-- $id (int)\n-- $name (text)"));
|
|
assert!(result.contains("DELETE FROM \"my_table\""));
|
|
assert!(result.contains("($id IS NULL AND \"id\" IS NULL OR \"id\" = $id)"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// INSERT - basic cases
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_insert_postgresql_basic() {
|
|
let cols = vec![col("id", "int4"), col("name", "text")];
|
|
let result = make_insert_query("my_table", &cols, DbType::Postgresql).unwrap();
|
|
|
|
assert!(result.contains("-- $1 id (int4)\n-- $2 name (text)"));
|
|
assert!(result.contains("INSERT INTO \"my_table\" (\"id\", \"name\") VALUES ($1, $2)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_mysql_basic() {
|
|
let cols = vec![col("id", "int"), col("name", "varchar")];
|
|
let result = make_insert_query("my_table", &cols, DbType::Mysql).unwrap();
|
|
|
|
assert!(result.contains("INSERT INTO `my_table` (`id`, `name`) VALUES (:id, :name)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_mssql_basic() {
|
|
let cols = vec![col("id", "int"), col("name", "nvarchar")];
|
|
let result = make_insert_query("my_table", &cols, DbType::MsSqlServer).unwrap();
|
|
|
|
assert!(result.contains("INSERT INTO [my_table] ([id], [name]) VALUES (@p1, @p2)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_snowflake_basic() {
|
|
let cols = vec![col("id", "int"), col("name", "text")];
|
|
let result = make_insert_query("my_table", &cols, DbType::Snowflake).unwrap();
|
|
|
|
assert!(result.contains("INSERT INTO \"my_table\" (\"id\", \"name\") VALUES (?, ?)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_bigquery_basic() {
|
|
let cols = vec![col("id", "INTEGER"), col("name", "STRING")];
|
|
let result = make_insert_query("my_table", &cols, DbType::Bigquery).unwrap();
|
|
|
|
assert!(result.contains("INSERT INTO `my_table` (`id`, `name`) VALUES (@id, @name)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_duckdb_basic() {
|
|
let cols = vec![col("id", "int"), col("name", "text")];
|
|
let result = make_insert_query("my_table", &cols, DbType::Duckdb).unwrap();
|
|
|
|
assert!(result.contains("INSERT INTO \"my_table\" (\"id\", \"name\") VALUES ($id, $name)"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// INSERT - with defaults and identity
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_insert_postgresql_skip_nextval() {
|
|
let mut id_col = col("id", "int4");
|
|
id_col.defaultvalue = "nextval('my_table_id_seq')".to_string();
|
|
let name_col = col("name", "text");
|
|
let result =
|
|
make_insert_query("my_table", &[id_col, name_col], DbType::Postgresql).unwrap();
|
|
|
|
// id should be skipped from insert columns (has nextval default in pg)
|
|
assert!(result.contains("INSERT INTO \"my_table\" (\"name\") VALUES ($1)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_with_hidden_column_and_user_default() {
|
|
let mut id_col = col("id", "int4");
|
|
id_col.hide_insert = Some(true);
|
|
id_col.default_user_value = Some(serde_json::Value::String("42".to_string()));
|
|
|
|
let name_col = col("name", "text");
|
|
let result =
|
|
make_insert_query("my_table", &[id_col, name_col], DbType::Postgresql).unwrap();
|
|
|
|
// name should be in insert params, id should be in defaults
|
|
assert!(result.contains("INSERT INTO \"my_table\" (\"name\", \"id\") VALUES ($1, '42')"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_with_hidden_column_null_default() {
|
|
let mut id_col = col("id", "int4");
|
|
id_col.hide_insert = Some(true);
|
|
id_col.default_value_null = Some(true);
|
|
|
|
let name_col = col("name", "text");
|
|
let result =
|
|
make_insert_query("my_table", &[id_col, name_col], DbType::Postgresql).unwrap();
|
|
|
|
assert!(result.contains("VALUES ($1, NULL)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_with_hidden_column_db_default_not_nullable() {
|
|
let mut id_col = col("id", "int4");
|
|
id_col.hide_insert = Some(true);
|
|
id_col.defaultvalue = "0".to_string();
|
|
id_col.isnullable = "NO".to_string();
|
|
|
|
let name_col = col("name", "text");
|
|
let result =
|
|
make_insert_query("my_table", &[id_col, name_col], DbType::Postgresql).unwrap();
|
|
|
|
// Column is hidden, not nullable, has db default, no user default -> omit (use db default)
|
|
assert!(result.contains("INSERT INTO \"my_table\" (\"name\") VALUES ($1)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_always_identity_column() {
|
|
let mut id_col = col("id", "int4");
|
|
id_col.isidentity = ColumnIdentity::Always;
|
|
id_col.hide_insert = Some(true);
|
|
|
|
let name_col = col("name", "text");
|
|
let result =
|
|
make_insert_query("my_table", &[id_col, name_col], DbType::Postgresql).unwrap();
|
|
|
|
// Always identity should be omitted
|
|
assert!(result.contains("INSERT INTO \"my_table\" (\"name\") VALUES ($1)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_error_not_nullable_no_default() {
|
|
let mut id_col = col("id", "int4");
|
|
id_col.hide_insert = Some(true);
|
|
id_col.isnullable = "NO".to_string();
|
|
|
|
let result = make_insert_query("my_table", &[id_col], DbType::Postgresql);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("not nullable"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_empty_table_error() {
|
|
let cols = vec![col("id", "int4")];
|
|
let result = make_insert_query("", &cols, DbType::Postgresql);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_default_values_only() {
|
|
// All columns hidden, none needs defaults -> DEFAULT VALUES
|
|
let result = make_insert_query("my_table", &[], DbType::Postgresql).unwrap();
|
|
assert_eq!(result, "INSERT INTO \"my_table\" DEFAULT VALUES");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// UPDATE - all DB types
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_update_postgresql() {
|
|
let update_col = simple_col("name", "text");
|
|
let where_cols = vec![simple_col("id", "int4"), simple_col("email", "text")];
|
|
let result = make_update_query("my_table", &update_col, &where_cols, DbType::Postgresql);
|
|
|
|
assert!(
|
|
result.contains("-- $1 value_to_update (text)\n-- $2 id (int4)\n-- $3 email (text)")
|
|
);
|
|
assert!(result.contains("UPDATE \"my_table\" SET \"name\" = $1"));
|
|
assert!(result.contains("($2 IS NULL AND \"id\" IS NULL OR \"id\" = $2)"));
|
|
assert!(result.contains("($3 IS NULL AND \"email\" IS NULL OR \"email\" = $3)"));
|
|
assert!(result.contains("RETURNING 1"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_mysql() {
|
|
let update_col = simple_col("name", "varchar");
|
|
let where_cols = vec![simple_col("id", "int")];
|
|
let result = make_update_query("my_table", &update_col, &where_cols, DbType::Mysql);
|
|
|
|
assert!(result.contains("UPDATE `my_table` SET `name` = :value_to_update"));
|
|
assert!(result.contains("(:id IS NULL AND `id` IS NULL OR `id` = :id)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_mssql() {
|
|
let update_col = simple_col("name", "nvarchar");
|
|
let where_cols = vec![simple_col("id", "int")];
|
|
let result = make_update_query("my_table", &update_col, &where_cols, DbType::MsSqlServer);
|
|
|
|
assert!(result.contains("UPDATE [my_table] SET [name] = @p1"));
|
|
assert!(result.contains("(@p2 IS NULL AND [id] IS NULL OR [id] = @p2)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_snowflake() {
|
|
let update_col = simple_col("name", "text");
|
|
let where_cols = vec![simple_col("id", "int")];
|
|
let result = make_update_query("my_table", &update_col, &where_cols, DbType::Snowflake);
|
|
|
|
// Snowflake doubles where columns
|
|
assert!(result.contains("-- ? value_to_update (text)\n-- ? id (int)\n-- ? id (int)"));
|
|
assert!(result.contains("UPDATE \"my_table\" SET \"name\" = ?"));
|
|
assert!(result.contains("(? = 'null' AND \"id\" IS NULL OR \"id\" = ?)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_bigquery() {
|
|
let update_col = simple_col("name", "STRING");
|
|
let where_cols = vec![simple_col("id", "INTEGER")];
|
|
let result = make_update_query("my_table", &update_col, &where_cols, DbType::Bigquery);
|
|
|
|
assert!(result.contains("UPDATE `my_table` SET `name` = @value_to_update"));
|
|
assert!(result.contains("(CAST(@id AS STRING) = 'null' AND `id` IS NULL OR `id` = @id)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_duckdb() {
|
|
let update_col = simple_col("name", "text");
|
|
let where_cols = vec![simple_col("id", "int")];
|
|
let result = make_update_query("my_table", &update_col, &where_cols, DbType::Duckdb);
|
|
|
|
assert!(result.contains("UPDATE \"my_table\" SET \"name\" = $value_to_update"));
|
|
assert!(result.contains("($id IS NULL AND \"id\" IS NULL OR \"id\" = $id)"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Integration-style tests: exact output matching for key cases
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_count_mssql_no_where() {
|
|
let cols = vec![col("id", "int")];
|
|
let result = make_count_query(DbType::MsSqlServer, "my_table", None, &cols, None).unwrap();
|
|
// MSSQL uses WHERE directly (no AND replacement)
|
|
assert!(result.contains("SELECT COUNT(*) as count FROM [my_table] WHERE "));
|
|
}
|
|
|
|
#[test]
|
|
fn test_count_mysql_no_where_uses_where_keyword() {
|
|
let cols = vec![col("id", "int")];
|
|
let result = make_count_query(DbType::Mysql, "my_table", None, &cols, None).unwrap();
|
|
// The AND should be replaced with WHERE
|
|
assert!(result.contains("FROM `my_table` WHERE "));
|
|
assert!(!result.contains("FROM `my_table` AND "));
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_multiple_conditions_joined_with_and() {
|
|
let cols = vec![col("a", "int4"), col("b", "text"), col("c", "bool")];
|
|
let result = make_delete_query("t", &cols, DbType::Postgresql);
|
|
// Check all three conditions are present and joined
|
|
assert!(result.contains("AND ($2 IS NULL AND \"b\" IS NULL OR \"b\" = $2)"));
|
|
assert!(result.contains("AND ($3 IS NULL AND \"c\" IS NULL OR \"c\" = $3)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_select_with_ignored_column() {
|
|
let cols = vec![
|
|
col("id", "int4"),
|
|
{
|
|
let mut c = col("hidden", "text");
|
|
c.ignored = Some(true);
|
|
c
|
|
},
|
|
col("name", "text"),
|
|
];
|
|
let result =
|
|
make_select_query("my_table", &cols, None, DbType::Postgresql, None, None).unwrap();
|
|
|
|
// hidden column should not appear in SELECT clause but does appear in ORDER BY
|
|
assert!(result.contains("SELECT \"id\"::text, \"name\"::text FROM"));
|
|
assert!(!result.contains("SELECT \"id\"::text, \"hidden\"::text"));
|
|
// It SHOULD appear in ORDER BY (order by uses all columnDefs, not just visible)
|
|
assert!(result.contains("$4 = 'hidden'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_with_override_default_value() {
|
|
let mut created_col = col("created_at", "timestamp");
|
|
created_col.hide_insert = Some(true);
|
|
created_col.override_default_value = Some(true);
|
|
created_col.default_user_value = Some(serde_json::Value::String("now()".to_string()));
|
|
// defaultvalue from DB is different
|
|
created_col.defaultvalue = "CURRENT_TIMESTAMP".to_string();
|
|
|
|
let name_col = col("name", "text");
|
|
let result =
|
|
make_insert_query("my_table", &[name_col, created_col], DbType::Postgresql).unwrap();
|
|
|
|
// Should use the user override value, not the DB default
|
|
assert!(result.contains("'now()'"));
|
|
assert!(!result.contains("CURRENT_TIMESTAMP"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_with_numeric_default_user_value() {
|
|
let mut col1 = col("priority", "int4");
|
|
col1.hide_insert = Some(true);
|
|
col1.default_user_value = Some(serde_json::json!(5));
|
|
|
|
let name_col = col("name", "text");
|
|
let result = make_insert_query("my_table", &[name_col, col1], DbType::Postgresql).unwrap();
|
|
|
|
// Numeric value should not be quoted
|
|
assert!(result.contains("VALUES ($1, 5)"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// WM_INTERNAL_DB expansion
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Helper: expand a marker and return just the SQL code string.
|
|
fn expand_code(marker: &str, lang: &ScriptLang) -> String {
|
|
try_expand_internal_db_query(marker, lang)
|
|
.expect("should be recognized as a marker")
|
|
.expect("expansion should succeed")
|
|
.code
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_select_marker() {
|
|
let marker = r#"-- WM_INTERNAL_DB_SELECT {"table":"my_table","columnDefs":[{"field":"id","datatype":"int4"},{"field":"name","datatype":"text"}]}"#;
|
|
let result = try_expand_internal_db_query(marker, &ScriptLang::Postgresql);
|
|
assert!(result.is_some());
|
|
let sql = result.unwrap().unwrap().code;
|
|
assert!(sql.contains("SELECT \"id\"::text, \"name\"::text FROM \"my_table\""));
|
|
assert!(sql.contains("LIMIT $1 OFFSET $2"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_select_with_where_and_options() {
|
|
let marker = r#"-- WM_INTERNAL_DB_SELECT {"table":"t","columnDefs":[{"field":"id","datatype":"int4"}],"whereClause":"active = true","limit":50,"offset":10,"fixPgIntTypes":true}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("WHERE active = true AND"));
|
|
assert!(sql.contains("pg_typeof(\"id\")"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_count_marker() {
|
|
let marker = r#"-- WM_INTERNAL_DB_COUNT {"table":"my_table","columnDefs":[{"field":"id","datatype":"int4"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert!(sql.contains("SELECT COUNT(*) as count FROM `my_table`"));
|
|
assert!(sql.contains(":quicksearch"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_delete_marker() {
|
|
let marker = r#"-- WM_INTERNAL_DB_DELETE {"table":"my_table","columns":[{"field":"id","datatype":"int4"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("DELETE FROM \"my_table\""));
|
|
assert!(sql.contains("RETURNING 1;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_insert_marker() {
|
|
let marker = r#"-- WM_INTERNAL_DB_INSERT {"table":"my_table","columns":[{"field":"id","datatype":"int4"},{"field":"name","datatype":"text"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("INSERT INTO \"my_table\" (\"id\", \"name\") VALUES ($1, $2)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_update_marker() {
|
|
let marker = r#"-- WM_INTERNAL_DB_UPDATE {"table":"my_table","column":{"field":"name","datatype":"text"},"columns":[{"field":"id","datatype":"int4"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("UPDATE \"my_table\" SET \"name\" = $1"));
|
|
assert!(sql.contains("RETURNING 1"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_with_ducklake() {
|
|
let marker = r#"-- WM_INTERNAL_DB_SELECT {"table":"my_table","columnDefs":[{"field":"id","datatype":"int4"}],"ducklake":"my_lake"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
|
assert!(sql.contains("ATTACH 'ducklake://my_lake' AS dl;USE dl;"));
|
|
assert!(sql.contains("SELECT \"id\" FROM \"my_table\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_not_a_marker() {
|
|
let regular_sql = "SELECT * FROM my_table";
|
|
let result = try_expand_internal_db_query(regular_sql, &ScriptLang::Postgresql);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_invalid_json() {
|
|
let marker = "-- WM_INTERNAL_DB_SELECT {invalid json}";
|
|
let result = try_expand_internal_db_query(marker, &ScriptLang::Postgresql);
|
|
assert!(result.unwrap().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_unknown_operation() {
|
|
let marker = "-- WM_INTERNAL_DB_TRUNCATE {}";
|
|
let result = try_expand_internal_db_query(marker, &ScriptLang::Postgresql);
|
|
assert!(result.unwrap().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_mysql_select() {
|
|
let marker = r#"-- WM_INTERNAL_DB_SELECT {"table":"users","columnDefs":[{"field":"id","datatype":"int"},{"field":"name","datatype":"varchar"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert!(sql.contains("SELECT `id`, `name` FROM `users`"));
|
|
assert!(sql.contains("LIMIT :limit OFFSET :offset"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_column_def_serde_roundtrip() {
|
|
let json = r#"{"field":"id","datatype":"int4","hideInsert":true,"defaultValueNull":true,"isidentity":"No"}"#;
|
|
let col: ColumnDef = serde_json::from_str(json).unwrap();
|
|
assert_eq!(col.field, "id");
|
|
assert_eq!(col.hide_insert, Some(true));
|
|
assert_eq!(col.default_value_null, Some(true));
|
|
assert_eq!(col.isidentity, ColumnIdentity::No);
|
|
}
|
|
|
|
#[test]
|
|
fn test_column_def_with_null_fields() {
|
|
let json = r#"{"field":"id","datatype":"int4","isprimarykey":null,"isenum":null,"isidentity":null,"isnullable":null,"defaultvalue":null}"#;
|
|
let col: ColumnDef = serde_json::from_str(json).unwrap();
|
|
assert_eq!(col.field, "id");
|
|
assert!(!col.isprimarykey);
|
|
assert!(!col.isenum);
|
|
assert_eq!(col.isidentity, ColumnIdentity::No);
|
|
assert_eq!(col.isnullable, "");
|
|
assert_eq!(col.defaultvalue, "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_wrap_ducklake_query() {
|
|
let query = "-- $1 limit\n-- $2 offset\nSELECT * FROM t";
|
|
let wrapped = wrap_ducklake_query(query, "my_lake");
|
|
assert_eq!(
|
|
wrapped,
|
|
"-- $1 limit\n-- $2 offset\nATTACH 'ducklake://my_lake' AS dl;USE dl;\nSELECT * FROM t"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Schema DDL helpers
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_table_ref_with_schema() {
|
|
assert_eq!(
|
|
table_ref("users", Some("myschema"), DbType::Postgresql),
|
|
r#""myschema"."users""#
|
|
);
|
|
assert_eq!(
|
|
table_ref("users", Some("myschema"), DbType::Snowflake),
|
|
r#""myschema"."users""#
|
|
);
|
|
assert_eq!(
|
|
table_ref("users", Some("dataset"), DbType::Bigquery),
|
|
"`dataset`.`users`"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_table_ref_without_schema() {
|
|
assert_eq!(table_ref("users", None, DbType::Postgresql), r#""users""#);
|
|
assert_eq!(
|
|
table_ref("users", Some(""), DbType::Postgresql),
|
|
r#""users""#
|
|
);
|
|
// Non-schema DBs ignore schema
|
|
assert_eq!(
|
|
table_ref("users", Some("myschema"), DbType::Mysql),
|
|
"`users`"
|
|
);
|
|
// DuckDB (ducklake) supports schemas
|
|
assert_eq!(
|
|
table_ref("users", Some("myschema"), DbType::Duckdb),
|
|
r#""myschema"."users""#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_datatype_has_length() {
|
|
assert!(datatype_has_length("varchar"));
|
|
assert!(datatype_has_length("VARCHAR"));
|
|
assert!(datatype_has_length("nvarchar"));
|
|
assert!(datatype_has_length("char"));
|
|
assert!(datatype_has_length("bit"));
|
|
assert!(datatype_has_length("varbinary"));
|
|
assert!(!datatype_has_length("int"));
|
|
assert!(!datatype_has_length("text"));
|
|
assert!(!datatype_has_length("boolean"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_default_value_simple() {
|
|
assert_eq!(
|
|
format_default_value("hello", "text", DbType::Mysql),
|
|
"'hello'"
|
|
);
|
|
assert_eq!(
|
|
format_default_value("hello", "text", DbType::Postgresql),
|
|
"CAST('hello' AS text)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_default_value_expression() {
|
|
// Expressions wrapped in braces are returned bare
|
|
assert_eq!(
|
|
format_default_value("{now()}", "timestamp", DbType::Postgresql),
|
|
"now()"
|
|
);
|
|
assert_eq!(
|
|
format_default_value("{CURRENT_TIMESTAMP}", "timestamp", DbType::Mysql),
|
|
"CURRENT_TIMESTAMP"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_default_value_empty() {
|
|
assert_eq!(format_default_value("", "text", DbType::Postgresql), "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_column_ddl_basic() {
|
|
let c = TableEditorColumn {
|
|
name: "email".to_string(),
|
|
datatype: "VARCHAR".to_string(),
|
|
primary_key: None,
|
|
default_value: None,
|
|
nullable: Some(false),
|
|
datatype_length: Some(255),
|
|
initial_name: None,
|
|
default_constraint_name: None,
|
|
};
|
|
let result = render_column_ddl(&c, DbType::Postgresql, false);
|
|
assert_eq!(result, "\"email\" VARCHAR(255) NOT NULL");
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_column_ddl_with_default_and_pk() {
|
|
let c = TableEditorColumn {
|
|
name: "id".to_string(),
|
|
datatype: "INT".to_string(),
|
|
primary_key: Some(true),
|
|
default_value: Some("{nextval('id_seq')}".to_string()),
|
|
nullable: Some(false),
|
|
datatype_length: None,
|
|
initial_name: None,
|
|
default_constraint_name: None,
|
|
};
|
|
let result = render_column_ddl(&c, DbType::Postgresql, true);
|
|
assert_eq!(
|
|
result,
|
|
"\"id\" INT NOT NULL DEFAULT nextval('id_seq') PRIMARY KEY"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_column_ddl_nullable_no_length() {
|
|
let c = TableEditorColumn {
|
|
name: "notes".to_string(),
|
|
datatype: "TEXT".to_string(),
|
|
primary_key: None,
|
|
default_value: None,
|
|
nullable: Some(true),
|
|
datatype_length: None,
|
|
initial_name: None,
|
|
default_constraint_name: None,
|
|
};
|
|
// nullable=true means no NOT NULL appended
|
|
let result = render_column_ddl(&c, DbType::Postgresql, false);
|
|
assert_eq!(result, "\"notes\" TEXT");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// DROP TABLE
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_expand_drop_table_simple() {
|
|
let marker = r#"-- WM_INTERNAL_DB_DROP_TABLE {"table":"users"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert_eq!(sql, "DROP TABLE \"users\";");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_drop_table_with_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_DROP_TABLE {"table":"users","schema":"myschema"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert_eq!(sql, "DROP TABLE \"myschema\".\"users\";");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_drop_table_mysql_ignores_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_DROP_TABLE {"table":"users","schema":"myschema"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert_eq!(sql, "DROP TABLE `users`;");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_drop_table_with_ducklake() {
|
|
let marker = r#"-- WM_INTERNAL_DB_DROP_TABLE {"table":"users","ducklake":"my_lake"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
|
assert!(sql.contains("ATTACH 'ducklake://my_lake' AS dl;USE dl;"));
|
|
assert!(sql.contains("DROP TABLE \"users\";"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_drop_table_ducklake_with_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_DROP_TABLE {"table":"events","schema":"analytics","ducklake":"my_lake"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
|
assert!(sql.contains("DROP TABLE \"analytics\".\"events\";"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// CREATE SCHEMA / DROP SCHEMA
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_expand_create_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_CREATE_SCHEMA {"schema":"new_schema"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert_eq!(sql, "CREATE SCHEMA \"new_schema\";");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_drop_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_DROP_SCHEMA {"schema":"old_schema"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert_eq!(sql, "DROP SCHEMA \"old_schema\" CASCADE;");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_rename_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_RENAME_SCHEMA {"schema":"old","new_schema":"new"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert_eq!(sql, "ALTER SCHEMA \"old\" RENAME TO \"new\";");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_create_schema_with_ducklake() {
|
|
let marker = r#"-- WM_INTERNAL_DB_CREATE_SCHEMA {"schema":"s","ducklake":"lake"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
|
assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n"));
|
|
assert!(sql.contains("CREATE SCHEMA \"s\";"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// CREATE TABLE
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_expand_create_table_basic() {
|
|
let marker = r#"-- WM_INTERNAL_DB_CREATE_TABLE {"name":"users","columns":[{"name":"id","datatype":"INT","primaryKey":true,"nullable":false},{"name":"name","datatype":"TEXT","nullable":true}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("CREATE TABLE \"users\" ("));
|
|
assert!(sql.contains("\"id\" INT NOT NULL PRIMARY KEY"));
|
|
assert!(sql.contains("\"name\" TEXT"));
|
|
assert!(sql.ends_with(");"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_create_table_with_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_CREATE_TABLE {"name":"users","columns":[{"name":"id","datatype":"INT","primaryKey":true}],"schema":"myschema"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("CREATE TABLE \"myschema\".\"users\" ("));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_create_table_composite_pk() {
|
|
let marker = r#"-- WM_INTERNAL_DB_CREATE_TABLE {"name":"t","columns":[{"name":"a","datatype":"INT","primaryKey":true},{"name":"b","datatype":"INT","primaryKey":true}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
// Composite PK should be separate constraint, not inline
|
|
assert!(!sql.contains("\"a\" INT PRIMARY KEY"));
|
|
assert!(sql.contains("PRIMARY KEY (\"a\", \"b\")"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_create_table_bigquery_pk_not_enforced() {
|
|
let marker = r#"-- WM_INTERNAL_DB_CREATE_TABLE {"name":"t","columns":[{"name":"id","datatype":"INT64","primaryKey":true}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Bigquery);
|
|
assert!(sql.contains("`id` INT64 NOT NULL PRIMARY KEY NOT ENFORCED"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_create_table_with_foreign_key() {
|
|
let marker = r#"-- WM_INTERNAL_DB_CREATE_TABLE {"name":"orders","columns":[{"name":"id","datatype":"INT","primaryKey":true},{"name":"user_id","datatype":"INT"}],"foreignKeys":[{"targetTable":"users","columns":[{"sourceColumn":"user_id","targetColumn":"id"}],"onDelete":"CASCADE","onUpdate":"NO ACTION"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("FOREIGN KEY (\"user_id\") REFERENCES \"users\" (\"id\")"));
|
|
assert!(sql.contains("ON DELETE CASCADE"));
|
|
assert!(!sql.contains("ON UPDATE NO ACTION")); // NO ACTION is omitted
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_create_table_with_default_value() {
|
|
let marker = r#"-- WM_INTERNAL_DB_CREATE_TABLE {"name":"t","columns":[{"name":"status","datatype":"TEXT","defaultValue":"active","nullable":false}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("\"status\" TEXT NOT NULL DEFAULT CAST('active' AS TEXT)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_create_table_varchar_with_length() {
|
|
let marker = r#"-- WM_INTERNAL_DB_CREATE_TABLE {"name":"t","columns":[{"name":"email","datatype":"VARCHAR","datatype_length":100}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert!(sql.contains("`email` VARCHAR(100)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_create_table_snowflake_default_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_CREATE_TABLE {"name":"t","columns":[{"name":"id","datatype":"INT"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Snowflake);
|
|
// Snowflake defaults to PUBLIC schema
|
|
assert!(sql.contains("CREATE TABLE \"PUBLIC\".\"t\" ("));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_create_table_with_ducklake() {
|
|
let marker = r#"-- WM_INTERNAL_DB_CREATE_TABLE {"name":"t","columns":[{"name":"id","datatype":"INT"}],"ducklake":"lake"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
|
assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n"));
|
|
assert!(sql.contains("CREATE TABLE \"t\" ("));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// ALTER TABLE
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_add_column() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"users","operations":[{"kind":"addColumn","column":{"name":"age","datatype":"INT","nullable":true}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("BEGIN;"));
|
|
assert!(sql.contains("ALTER TABLE \"users\" ADD COLUMN \"age\" INT;"));
|
|
assert!(sql.contains("COMMIT;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_drop_column() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"users","operations":[{"kind":"dropColumn","name":"old_col"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("ALTER TABLE \"users\" DROP COLUMN \"old_col\";"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_rename_table() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"old_name","operations":[{"kind":"renameTable","to":"new_name"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("ALTER TABLE \"old_name\" RENAME TO \"new_name\";"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_rename_table_snowflake_with_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"old","operations":[{"kind":"renameTable","to":"new"}],"schema":"MY_SCHEMA"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Snowflake);
|
|
assert!(sql.contains("ALTER TABLE \"MY_SCHEMA\".\"old\" RENAME TO \"MY_SCHEMA\".\"new\";"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_rename_table_mssql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"old_name","operations":[{"kind":"renameTable","to":"new_name"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mssql);
|
|
assert!(sql.contains("EXEC sp_rename 'old_name', 'new_name';"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_rename_table_mssql_with_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"old","operations":[{"kind":"renameTable","to":"new"}],"schema":"dbo"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mssql);
|
|
assert!(
|
|
sql.contains("EXEC sp_rename 'dbo.old', 'new';"),
|
|
"actual sql: {}",
|
|
sql
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_add_foreign_key() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"orders","operations":[{"kind":"addForeignKey","foreignKey":{"targetTable":"users","columns":[{"sourceColumn":"user_id","targetColumn":"id"}],"onDelete":"CASCADE","onUpdate":"NO ACTION"}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("ALTER TABLE \"orders\" ADD CONSTRAINT fk_"));
|
|
assert!(sql.contains("FOREIGN KEY (\"user_id\") REFERENCES \"users\" (\"id\")"));
|
|
assert!(sql.contains("ON DELETE CASCADE"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_drop_foreign_key_mysql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"orders","operations":[{"kind":"dropForeignKey","fk_constraint_name":"fk_orders_user"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
// MySQL uses DROP FOREIGN KEY
|
|
assert!(sql.contains("ALTER TABLE `orders` DROP FOREIGN KEY `fk_orders_user`"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_drop_foreign_key_postgresql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"orders","operations":[{"kind":"dropForeignKey","fk_constraint_name":"fk_orders_user"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
// PostgreSQL uses DROP CONSTRAINT
|
|
assert!(sql.contains("ALTER TABLE \"orders\" DROP CONSTRAINT \"fk_orders_user\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_add_primary_key() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"addPrimaryKey","columns":["a","b"]}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("ALTER TABLE \"t\" ADD PRIMARY KEY (\"a\", \"b\");"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_add_primary_key_snowflake() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"addPrimaryKey","columns":["id"]}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Snowflake);
|
|
// Snowflake uses named constraint
|
|
assert!(sql.contains("ADD CONSTRAINT \"t_pk\" PRIMARY KEY (\"id\")"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_add_primary_key_bigquery() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"addPrimaryKey","columns":["id"]}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Bigquery);
|
|
assert!(sql.contains("ADD PRIMARY KEY (`id`) NOT ENFORCED;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_drop_primary_key_mysql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"dropPrimaryKey"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert!(sql.contains("ALTER TABLE `t` DROP PRIMARY KEY;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_drop_primary_key_with_constraint() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"dropPrimaryKey","pk_constraint_name":"pk_t"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("ALTER TABLE \"t\" DROP CONSTRAINT \"pk_t\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_alter_column_datatype() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"alterColumn","original":{"name":"col","datatype":"INT"},"changes":{"datatype":"BIGINT"}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("ALTER TABLE \"t\" ALTER COLUMN \"col\" TYPE BIGINT;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_alter_column_datatype_mysql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"alterColumn","original":{"name":"col","datatype":"INT"},"changes":{"datatype":"BIGINT"}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert!(sql.contains("ALTER TABLE `t` MODIFY COLUMN `col` BIGINT;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_alter_column_datatype_mssql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"alterColumn","original":{"name":"col","datatype":"INT"},"changes":{"datatype":"BIGINT"}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mssql);
|
|
assert!(sql.contains("ALTER TABLE [t] ALTER COLUMN [col] BIGINT;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_alter_column_nullable() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"alterColumn","original":{"name":"col","datatype":"INT"},"changes":{"nullable":false}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("ALTER TABLE \"t\" ALTER COLUMN \"col\" SET NOT NULL;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_alter_column_drop_not_null() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"alterColumn","original":{"name":"col","datatype":"INT"},"changes":{"nullable":true}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("ALTER TABLE \"t\" ALTER COLUMN \"col\" DROP NOT NULL;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_alter_column_nullable_mssql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"alterColumn","original":{"name":"col","datatype":"INT"},"changes":{"nullable":false}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mssql);
|
|
assert!(sql.contains("ALTER TABLE [t] ALTER COLUMN [col] INT NOT NULL;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_alter_column_nullable_mysql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"alterColumn","original":{"name":"col","datatype":"INT"},"changes":{"nullable":true}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert!(sql.contains("ALTER TABLE `t` MODIFY COLUMN `col` INT NULL;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_alter_column_rename() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"alterColumn","original":{"name":"old_col","datatype":"INT"},"changes":{"name":"new_col"}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("ALTER TABLE \"t\" RENAME COLUMN \"old_col\" TO \"new_col\";"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_alter_column_rename_mssql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"alterColumn","original":{"name":"old_col","datatype":"INT"},"changes":{"name":"new_col"}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mssql);
|
|
assert!(sql.contains("EXEC sp_rename '[t].old_col', 'new_col', 'COLUMN';"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_alter_column_set_default() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"alterColumn","original":{"name":"col","datatype":"TEXT"},"changes":{"defaultValue":"hello"}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql
|
|
.contains("ALTER TABLE \"t\" ALTER COLUMN \"col\" SET DEFAULT CAST('hello' AS TEXT);"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_alter_column_drop_default() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"alterColumn","original":{"name":"col","datatype":"TEXT","defaultValue":"old"},"changes":{"defaultValue":null}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("ALTER TABLE \"t\" ALTER COLUMN \"col\" DROP DEFAULT;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_alter_column_drop_default_mssql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"alterColumn","original":{"name":"col","datatype":"INT","defaultValue":"0"},"defaultConstraintName":"DF_t_col","changes":{"defaultValue":null}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mssql);
|
|
assert!(sql.contains("ALTER TABLE [t] DROP CONSTRAINT [DF_t_col]"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_alter_column_varchar_with_length() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"alterColumn","original":{"name":"col","datatype":"VARCHAR","datatype_length":50},"changes":{"datatype":"VARCHAR","datatype_length":100}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("ALTER TABLE \"t\" ALTER COLUMN \"col\" TYPE VARCHAR(100);"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_transactional_postgresql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"addColumn","column":{"name":"a","datatype":"INT"}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.starts_with("BEGIN;\n"));
|
|
assert!(sql.ends_with("\nCOMMIT;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_transactional_mssql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"addColumn","column":{"name":"a","datatype":"INT"}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mssql);
|
|
assert!(sql.starts_with("BEGIN TRANSACTION;\n"));
|
|
assert!(sql.ends_with("\nCOMMIT TRANSACTION;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_no_transaction_mysql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"addColumn","column":{"name":"a","datatype":"INT"}}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert!(!sql.contains("BEGIN"));
|
|
assert!(!sql.contains("COMMIT"));
|
|
assert!(sql.contains("ALTER TABLE `t` ADD COLUMN `a` INT NOT NULL;"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_with_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"addColumn","column":{"name":"a","datatype":"INT"}}],"schema":"myschema"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains(r#"ALTER TABLE "myschema"."t" ADD COLUMN "a" INT NOT NULL;"#));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_multiple_operations() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"addColumn","column":{"name":"a","datatype":"INT"}},{"kind":"dropColumn","name":"b"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains(r#"ADD COLUMN "a" INT NOT NULL;"#));
|
|
assert!(sql.contains(r#"DROP COLUMN "b";"#));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_empty_operations() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert_eq!(sql, "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_alter_table_with_ducklake() {
|
|
let marker = r#"-- WM_INTERNAL_DB_ALTER_TABLE {"name":"t","operations":[{"kind":"addColumn","column":{"name":"a","datatype":"INT"}}],"ducklake":"lake"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
|
assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n"));
|
|
assert!(sql.contains(r#"ALTER TABLE "t" ADD COLUMN "a" INT NOT NULL;"#));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// LOAD_TABLE_METADATA
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_postgresql_single_table() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"users"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("a.attname as field"));
|
|
assert!(sql.contains("pg_catalog.format_type"));
|
|
assert!(sql.contains("relname = 'users'"));
|
|
assert!(sql.contains("ns.nspname = 'public'"));
|
|
// Single table should not have table_name/schema_name columns
|
|
assert!(!sql.contains("table_name"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_postgresql_all_tables() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("schema_name"));
|
|
assert!(sql.contains("table_name"));
|
|
assert!(sql.contains("c.relkind = 'r'"));
|
|
assert!(sql.contains("has_schema_privilege(ns.oid, 'USAGE')"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_postgresql_schema_table() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"myschema.users"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("relname = 'users'"));
|
|
assert!(sql.contains("ns.nspname = 'myschema'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_mysql_single_table() {
|
|
let marker =
|
|
r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"users","databaseName":"mydb"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert!(sql.contains("COLUMN_NAME as field"));
|
|
assert!(sql.contains("TABLE_NAME = 'users'"));
|
|
assert!(sql.contains("TABLE_SCHEMA = 'mydb'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_mysql_single_table_uses_session_db() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"users"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert!(sql.contains("TABLE_NAME = 'users'"));
|
|
assert!(sql.contains("TABLE_SCHEMA = DATABASE()"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_mysql_all_tables_uses_session_db() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert!(sql.contains("TABLE_NAME as table_name"));
|
|
assert!(sql.contains("TABLE_SCHEMA = DATABASE()"));
|
|
assert!(sql.contains("TABLE_SCHEMA NOT IN"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_mysql_all_tables_with_database_name() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"databaseName":"mydb"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert!(sql.contains("TABLE_NAME as table_name"));
|
|
assert!(sql.contains("TABLE_SCHEMA = 'mydb'"));
|
|
assert!(sql.contains("TABLE_SCHEMA NOT IN"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_mssql_single_table() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"users"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mssql);
|
|
assert!(sql.contains("c.COLUMN_NAME as field"));
|
|
assert!(sql.contains("c.TABLE_NAME = 'users'"));
|
|
assert!(sql.contains("default_constraint_name"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_mssql_all_tables() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mssql);
|
|
assert!(sql.contains("TABLE_NAME as table_name"));
|
|
assert!(sql.contains("TABLE_SCHEMA != 'sys'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_snowflake_single_table() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"MY_TABLE"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Snowflake);
|
|
assert!(sql.contains("COLUMN_NAME as field"));
|
|
assert!(sql.contains("table_name = 'MY_TABLE'"));
|
|
assert!(sql.contains("table_schema = 'PUBLIC'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_snowflake_all_tables() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Snowflake);
|
|
assert!(sql.contains("table_name as table_name"));
|
|
assert!(sql.contains("table_schema as schema_name"));
|
|
assert!(sql.contains("table_schema <> 'INFORMATION_SCHEMA'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_bigquery_single_table() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"dataset.my_table"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Bigquery);
|
|
assert!(sql.contains("`dataset`.INFORMATION_SCHEMA.COLUMNS"));
|
|
assert!(sql.contains("TABLE_NAME = 'my_table'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_bigquery_all_tables_bun_script() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {}"#;
|
|
let expanded = try_expand_internal_db_query(marker, &ScriptLang::Bigquery)
|
|
.expect("should be recognized as a marker")
|
|
.expect("expansion should succeed");
|
|
// BigQuery all-tables returns a Bun script, not SQL
|
|
assert_eq!(expanded.language_override, Some(ScriptLang::Bun));
|
|
assert!(expanded.code.contains("BigQuery"));
|
|
assert!(expanded.code.contains("INFORMATION_SCHEMA.COLUMNS"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_duckdb() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"users"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
|
assert!(sql.contains("COLUMN_NAME as field"));
|
|
assert!(sql.contains("TABLE_NAME = 'users'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_with_ducklake() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"users","ducklake":"lake"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
|
assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n"));
|
|
assert!(sql.contains("table_catalog = current_database()"));
|
|
// Unqualified table defaults to the "main" schema.
|
|
assert!(sql.contains("TABLE_NAME = 'users' AND TABLE_SCHEMA = 'main'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_ducklake_qualified_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"analytics.events","ducklake":"lake"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
|
assert!(sql.contains("TABLE_NAME = 'events' AND TABLE_SCHEMA = 'analytics'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_load_table_metadata_ducklake_all_tables() {
|
|
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"ducklake":"lake"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
|
assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n"));
|
|
// All-tables listing scopes to the ducklake catalog and exposes the schema per table.
|
|
assert!(sql.contains("table_catalog = current_database()"));
|
|
assert!(sql.contains("TABLE_SCHEMA as schema_name"));
|
|
assert!(!sql.contains("TABLE_NAME = '"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// FOREIGN_KEYS
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_expand_foreign_keys_postgresql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_FOREIGN_KEYS {"table":"users"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("tc.constraint_name as fk_constraint_name"));
|
|
assert!(sql.contains("tc.table_name = 'users'"));
|
|
assert!(sql.contains("tc.table_schema = 'public'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_foreign_keys_with_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_FOREIGN_KEYS {"table":"users","schema":"myschema"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("tc.table_schema = 'myschema'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_foreign_keys_schema_in_table_name() {
|
|
let marker = r#"-- WM_INTERNAL_DB_FOREIGN_KEYS {"table":"myschema.users"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("tc.table_name = 'users'"));
|
|
assert!(sql.contains("tc.table_schema = 'myschema'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_foreign_keys_mysql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_FOREIGN_KEYS {"table":"orders"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert!(sql.contains("kcu.TABLE_NAME = 'orders'"));
|
|
assert!(sql.contains("TABLE_SCHEMA = DATABASE()"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_foreign_keys_mysql_with_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_FOREIGN_KEYS {"table":"orders","schema":"mydb"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert!(sql.contains("TABLE_SCHEMA = 'mydb'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_foreign_keys_mssql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_FOREIGN_KEYS {"table":"orders"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mssql);
|
|
assert!(sql.contains("t.name = 'orders'"));
|
|
assert!(sql.contains("s.name = 'dbo'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_foreign_keys_snowflake() {
|
|
let marker = r#"-- WM_INTERNAL_DB_FOREIGN_KEYS {"table":"orders","schema":"MY_SCHEMA"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Snowflake);
|
|
assert!(sql.contains("SHOW IMPORTED KEYS IN TABLE \"MY_SCHEMA\".\"orders\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_foreign_keys_bigquery() {
|
|
let marker = r#"-- WM_INTERNAL_DB_FOREIGN_KEYS {"table":"dataset.orders"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Bigquery);
|
|
assert!(sql.contains("INFORMATION_SCHEMA.TABLE_CONSTRAINTS"));
|
|
assert!(sql.contains("tc.table_name = 'orders'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_foreign_keys_duckdb() {
|
|
let marker = r#"-- WM_INTERNAL_DB_FOREIGN_KEYS {"table":"orders"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
|
assert!(sql.contains("fk_constraint.table_name = 'orders'"));
|
|
assert!(sql.contains("fk_constraint.table_schema = 'main'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_foreign_keys_with_ducklake() {
|
|
let marker = r#"-- WM_INTERNAL_DB_FOREIGN_KEYS {"table":"orders","ducklake":"lake"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
|
assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// PRIMARY_KEY_CONSTRAINT
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_expand_primary_key_constraint_postgresql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_PRIMARY_KEY_CONSTRAINT {"table":"users"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("constraint_type = 'PRIMARY KEY'"));
|
|
assert!(sql.contains("tc.table_name = 'users'"));
|
|
assert!(sql.contains("tc.table_schema = 'public'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_primary_key_constraint_with_schema() {
|
|
let marker = r#"-- WM_INTERNAL_DB_PRIMARY_KEY_CONSTRAINT {"table":"myschema.users"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("tc.table_name = 'users'"));
|
|
assert!(sql.contains("tc.table_schema = 'myschema'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_primary_key_constraint_mysql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_PRIMARY_KEY_CONSTRAINT {"table":"users"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mysql);
|
|
assert!(sql.contains("CONSTRAINT_TYPE = 'PRIMARY KEY'"));
|
|
assert!(sql.contains("TABLE_NAME = 'users'"));
|
|
assert!(sql.contains("TABLE_SCHEMA = DATABASE()"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_primary_key_constraint_mssql() {
|
|
let marker = r#"-- WM_INTERNAL_DB_PRIMARY_KEY_CONSTRAINT {"table":"users"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Mssql);
|
|
assert!(sql.contains("kc.type = 'PK'"));
|
|
assert!(sql.contains("t.name = 'users'"));
|
|
assert!(sql.contains("s.name = 'dbo'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_primary_key_constraint_snowflake() {
|
|
let marker =
|
|
r#"-- WM_INTERNAL_DB_PRIMARY_KEY_CONSTRAINT {"table":"users","schema":"MY_SCHEMA"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Snowflake);
|
|
assert!(sql.contains("constraint_type = 'PRIMARY KEY'"));
|
|
assert!(sql.contains("table_name = 'users'"));
|
|
assert!(sql.contains("table_schema = 'MY_SCHEMA'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_primary_key_constraint_bigquery() {
|
|
let marker = r#"-- WM_INTERNAL_DB_PRIMARY_KEY_CONSTRAINT {"table":"dataset.users"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Bigquery);
|
|
assert!(sql.contains("INFORMATION_SCHEMA.TABLE_CONSTRAINTS"));
|
|
assert!(sql.contains("table_name = 'users'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_primary_key_constraint_duckdb() {
|
|
let marker = r#"-- WM_INTERNAL_DB_PRIMARY_KEY_CONSTRAINT {"table":"users"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
|
assert!(sql.contains("constraint_type = 'PRIMARY KEY'"));
|
|
assert!(sql.contains("tc.table_name = 'users'"));
|
|
assert!(sql.contains("tc.table_schema = 'main'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_primary_key_constraint_with_ducklake() {
|
|
let marker =
|
|
r#"-- WM_INTERNAL_DB_PRIMARY_KEY_CONSTRAINT {"table":"users","ducklake":"lake"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
|
assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// SNOWFLAKE_PRIMARY_KEYS
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_expand_snowflake_primary_keys_single_table() {
|
|
let marker = r#"-- WM_INTERNAL_DB_SNOWFLAKE_PRIMARY_KEYS {"table":"MY_SCHEMA.MY_TABLE"}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Snowflake);
|
|
assert_eq!(sql, "SHOW PRIMARY KEYS IN TABLE \"MY_SCHEMA\".\"MY_TABLE\"");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_snowflake_primary_keys_all() {
|
|
let marker = r#"-- WM_INTERNAL_DB_SNOWFLAKE_PRIMARY_KEYS {}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Snowflake);
|
|
assert_eq!(sql, "SHOW PRIMARY KEYS IN ACCOUNT");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Primary key filtering for DELETE / UPDATE
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_delete_uses_pk_columns_only() {
|
|
let marker = r#"-- WM_INTERNAL_DB_DELETE {"table":"users","columns":[{"field":"id","datatype":"int4","isprimarykey":true},{"field":"name","datatype":"text","isprimarykey":false},{"field":"email","datatype":"text","isprimarykey":false}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
// Should only use 'id' in WHERE, not 'name' or 'email'
|
|
assert!(sql.contains("\"id\" = $1"));
|
|
assert!(!sql.contains("\"name\""));
|
|
assert!(!sql.contains("\"email\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_falls_back_to_all_when_no_pk() {
|
|
let marker = r#"-- WM_INTERNAL_DB_DELETE {"table":"users","columns":[{"field":"id","datatype":"int4"},{"field":"name","datatype":"text"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
// No PK columns -> should use all columns
|
|
assert!(sql.contains("\"id\" = $1"));
|
|
assert!(sql.contains("\"name\" = $2"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_uses_pk_columns_only() {
|
|
let marker = r#"-- WM_INTERNAL_DB_UPDATE {"table":"users","column":{"field":"name","datatype":"text"},"columns":[{"field":"id","datatype":"int4","isprimarykey":true},{"field":"name","datatype":"text","isprimarykey":false},{"field":"email","datatype":"text","isprimarykey":false}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
// SET clause should target 'name'
|
|
assert!(sql.contains("SET \"name\" = $1"));
|
|
// WHERE should only use 'id', not 'name' or 'email'
|
|
assert!(sql.contains("\"id\" = $2"));
|
|
assert!(!sql.contains("\"email\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_falls_back_to_all_when_no_pk() {
|
|
let marker = r#"-- WM_INTERNAL_DB_UPDATE {"table":"users","column":{"field":"name","datatype":"text"},"columns":[{"field":"id","datatype":"int4"},{"field":"name","datatype":"text"}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("SET \"name\" = $1"));
|
|
assert!(sql.contains("\"id\" = $2"));
|
|
assert!(sql.contains("\"name\" = $3"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_composite_pk() {
|
|
let marker = r#"-- WM_INTERNAL_DB_DELETE {"table":"order_items","columns":[{"field":"order_id","datatype":"int4","isprimarykey":true},{"field":"item_id","datatype":"int4","isprimarykey":true},{"field":"quantity","datatype":"int4","isprimarykey":false}]}"#;
|
|
let sql = expand_code(marker, &ScriptLang::Postgresql);
|
|
assert!(sql.contains("\"order_id\" = $1"));
|
|
assert!(sql.contains("\"item_id\" = $2"));
|
|
assert!(!sql.contains("\"quantity\""));
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Full schema introspection types and logic (used by get_datatable_full_schema)
|
|
// ============================================================================
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct FullSchemaColumn {
|
|
pub name: String,
|
|
pub datatype: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub primary_key: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub default_value: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub nullable: Option<bool>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct FullSchemaForeignKeyColumn {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub source_column: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub target_column: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct FullSchemaForeignKey {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub target_table: Option<String>,
|
|
pub columns: Vec<FullSchemaForeignKeyColumn>,
|
|
pub on_delete: String,
|
|
pub on_update: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub fk_constraint_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct FullSchemaTable {
|
|
pub name: String,
|
|
pub columns: Vec<FullSchemaColumn>,
|
|
pub foreign_keys: Vec<FullSchemaForeignKey>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub pk_constraint_name: Option<String>,
|
|
}
|
|
|
|
/// Full database schema: { schema_name: { table_name: FullSchemaTable } }
|
|
pub type FullDatabaseSchema =
|
|
std::collections::HashMap<String, std::collections::HashMap<String, FullSchemaTable>>;
|
|
|
|
fn pg_action_to_string(action: &str) -> String {
|
|
match action {
|
|
"a" => "NO ACTION".to_string(),
|
|
"r" => "RESTRICT".to_string(),
|
|
"c" => "CASCADE".to_string(),
|
|
"n" => "SET NULL".to_string(),
|
|
"d" => "SET DEFAULT".to_string(),
|
|
_ => "NO ACTION".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Rows of a simple-protocol result, dropping the framing messages.
|
|
fn simple_query_rows(
|
|
messages: Vec<tokio_postgres::SimpleQueryMessage>,
|
|
) -> Vec<tokio_postgres::SimpleQueryRow> {
|
|
messages
|
|
.into_iter()
|
|
.filter_map(|m| match m {
|
|
tokio_postgres::SimpleQueryMessage::Row(row) => Some(row),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn required_str<'a>(
|
|
row: &'a tokio_postgres::SimpleQueryRow,
|
|
column: &str,
|
|
) -> Result<&'a str, String> {
|
|
row.try_get(column)
|
|
.map_err(|e| format!("Failed to read column {}: {}", column, pg_error_message(&e)))?
|
|
.ok_or_else(|| format!("Unexpected NULL in column {}", column))
|
|
}
|
|
|
|
/// Introspect a PostgreSQL database and return the full schema.
|
|
/// Takes a connected tokio_postgres Client.
|
|
///
|
|
/// Both statements go through the simple query protocol. The extended protocol allocates a
|
|
/// named prepared statement per call and closes it when the statement handle drops; behind a
|
|
/// transaction-pooling proxy those names are shared with, and outlive, other sessions on the
|
|
/// same backend, and the exchange then stalls with no reply — the connection never becomes
|
|
/// idle again and the request hangs. Neither statement takes parameters, so nothing here
|
|
/// needs the extended protocol.
|
|
pub async fn pg_get_full_schema(
|
|
client: &tokio_postgres::Client,
|
|
) -> Result<FullDatabaseSchema, String> {
|
|
// Primary-key and default-value info are joined in (a table has at most one
|
|
// primary-key constraint, so `pkc` stays 1:1) rather than fetched via
|
|
// per-column correlated subqueries — on large catalogs those subqueries run
|
|
// once per column and make the introspection time out.
|
|
let column_rows = client
|
|
.simple_query(
|
|
"SELECT
|
|
ns.nspname AS schema_name,
|
|
c.relname AS table_name,
|
|
a.attname AS column_name,
|
|
pg_catalog.format_type(a.atttypid, a.atttypmod) AS datatype,
|
|
substring(pg_catalog.pg_get_expr(ad.adbin, ad.adrelid, true) for 128) AS default_value,
|
|
NOT a.attnotnull AS nullable,
|
|
COALESCE(pkc.conkey @> ARRAY[a.attnum], false) AS is_primary_key,
|
|
pkc.conname AS pk_constraint_name
|
|
FROM pg_catalog.pg_attribute a
|
|
JOIN pg_catalog.pg_class c ON a.attrelid = c.oid
|
|
JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid
|
|
LEFT JOIN pg_catalog.pg_attrdef ad
|
|
ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum AND a.atthasdef
|
|
LEFT JOIN pg_catalog.pg_constraint pkc
|
|
ON pkc.conrelid = c.oid AND pkc.contype = 'p'
|
|
WHERE c.relkind = 'r'
|
|
AND a.attnum > 0
|
|
AND NOT a.attisdropped
|
|
AND ns.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
ORDER BY ns.nspname, c.relname, a.attnum",
|
|
)
|
|
.await
|
|
.map(simple_query_rows)
|
|
.map_err(|e| format!("Failed to query columns: {}", pg_error_message(&e)))?;
|
|
|
|
let fk_rows = client
|
|
.simple_query(
|
|
"SELECT
|
|
ns.nspname AS schema_name,
|
|
c.relname AS table_name,
|
|
con.conname AS fk_constraint_name,
|
|
att_src.attname AS source_column,
|
|
ns_ref.nspname AS ref_schema,
|
|
c_ref.relname AS ref_table,
|
|
att_ref.attname AS ref_column,
|
|
con.confdeltype::text AS on_delete,
|
|
con.confupdtype::text AS on_update
|
|
FROM pg_catalog.pg_constraint con
|
|
JOIN pg_catalog.pg_class c ON con.conrelid = c.oid
|
|
JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid
|
|
JOIN pg_catalog.pg_class c_ref ON con.confrelid = c_ref.oid
|
|
JOIN pg_catalog.pg_namespace ns_ref ON c_ref.relnamespace = ns_ref.oid
|
|
CROSS JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS u(src_attnum, ref_attnum, ord)
|
|
JOIN pg_catalog.pg_attribute att_src ON att_src.attrelid = c.oid AND att_src.attnum = u.src_attnum
|
|
JOIN pg_catalog.pg_attribute att_ref ON att_ref.attrelid = c_ref.oid AND att_ref.attnum = u.ref_attnum
|
|
WHERE con.contype = 'f'
|
|
AND ns.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
ORDER BY ns.nspname, c.relname, con.conname, u.ord",
|
|
)
|
|
.await
|
|
.map(simple_query_rows)
|
|
.map_err(|e| format!("Failed to query foreign keys: {}", pg_error_message(&e)))?;
|
|
|
|
let mut result: FullDatabaseSchema = std::collections::HashMap::new();
|
|
|
|
for row in &column_rows {
|
|
let schema_name = required_str(row, "schema_name")?;
|
|
let table_name = required_str(row, "table_name")?;
|
|
let column_name = required_str(row, "column_name")?;
|
|
let datatype = required_str(row, "datatype")?;
|
|
let default_value: Option<&str> = row.get("default_value");
|
|
let nullable = required_str(row, "nullable")? == "t";
|
|
let is_primary_key = required_str(row, "is_primary_key")? == "t";
|
|
let pk_constraint_name: Option<&str> = row.get("pk_constraint_name");
|
|
|
|
let schema_tables = result.entry(schema_name.to_string()).or_default();
|
|
let table = schema_tables
|
|
.entry(table_name.to_string())
|
|
.or_insert_with(|| FullSchemaTable {
|
|
name: table_name.to_string(),
|
|
columns: vec![],
|
|
foreign_keys: vec![],
|
|
pk_constraint_name: pk_constraint_name.map(|s| s.to_string()),
|
|
});
|
|
|
|
table.columns.push(FullSchemaColumn {
|
|
name: column_name.to_string(),
|
|
datatype: datatype.to_string(),
|
|
primary_key: if is_primary_key { Some(true) } else { None },
|
|
default_value: default_value.map(|s| s.to_string()),
|
|
nullable: Some(nullable),
|
|
});
|
|
}
|
|
|
|
let mut fk_map: std::collections::HashMap<
|
|
(String, String, String),
|
|
(
|
|
Option<String>,
|
|
Vec<FullSchemaForeignKeyColumn>,
|
|
String,
|
|
String,
|
|
),
|
|
> = std::collections::HashMap::new();
|
|
|
|
for row in &fk_rows {
|
|
let schema_name = required_str(row, "schema_name")?;
|
|
let table_name = required_str(row, "table_name")?;
|
|
let fk_name = required_str(row, "fk_constraint_name")?;
|
|
let source_column = required_str(row, "source_column")?;
|
|
let ref_schema = required_str(row, "ref_schema")?;
|
|
let ref_table = required_str(row, "ref_table")?;
|
|
let ref_column = required_str(row, "ref_column")?;
|
|
let on_delete = required_str(row, "on_delete")?;
|
|
let on_update = required_str(row, "on_update")?;
|
|
|
|
let target_table = if ref_schema == schema_name {
|
|
ref_table.to_string()
|
|
} else {
|
|
format!("{}.{}", ref_schema, ref_table)
|
|
};
|
|
|
|
let key = (
|
|
schema_name.to_string(),
|
|
table_name.to_string(),
|
|
fk_name.to_string(),
|
|
);
|
|
let entry = fk_map.entry(key).or_insert_with(|| {
|
|
(
|
|
Some(target_table.clone()),
|
|
vec![],
|
|
pg_action_to_string(on_delete),
|
|
pg_action_to_string(on_update),
|
|
)
|
|
});
|
|
entry.1.push(FullSchemaForeignKeyColumn {
|
|
source_column: Some(source_column.to_string()),
|
|
target_column: Some(ref_column.to_string()),
|
|
});
|
|
}
|
|
|
|
let mut fk_entries: Vec<_> = fk_map.into_iter().collect();
|
|
fk_entries.sort_by(|a, b| a.0.cmp(&b.0));
|
|
|
|
for ((schema_name, table_name, fk_name), (target_table, columns, on_delete, on_update)) in
|
|
fk_entries
|
|
{
|
|
if let Some(schema_tables) = result.get_mut(&schema_name) {
|
|
if let Some(table) = schema_tables.get_mut(&table_name) {
|
|
table.foreign_keys.push(FullSchemaForeignKey {
|
|
target_table,
|
|
columns,
|
|
on_delete,
|
|
on_update,
|
|
fk_constraint_name: Some(fk_name),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(result)
|
|
}
|