Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd5be5ecc7 | |||
| 88830ed06a | |||
| ec3c7c304c | |||
| 41b7e9a049 | |||
| e9606b5f6d | |||
| ef99e6c676 | |||
| c30a6114b9 | |||
| fe9d58e037 | |||
| 628b250db6 | |||
| 784373a254 |
@@ -0,0 +1,7 @@
|
||||
# Revisions to ignore in `git blame` — bulk, mechanical, no-behaviour-change
|
||||
# commits whose authorship is noise. Enable locally with:
|
||||
# git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||
# (Forges that support it, e.g. GitHub, pick this up automatically.)
|
||||
|
||||
# style: format the whole tree with cargo fmt (stock defaults, #35)
|
||||
41b7e9a04992cd9708f1775b57044de838b48b85
|
||||
@@ -2,9 +2,10 @@
|
||||
# build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps
|
||||
# just enter the flake devShell and run cargo.
|
||||
#
|
||||
# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-ci-002: the tree
|
||||
# isn't clean under stock rustfmt; revisit on main). The release job (static
|
||||
# binary for D2) and the platform matrix layer on later, step by step.
|
||||
# Gate = fmt + clippy + test. The fmt gate (`cargo fmt --check`, stock defaults)
|
||||
# was enabled once the tree was reformatted on main (ADR-ci-002 Amendment 1 /
|
||||
# issue #35). The release job (static binary for D2) and the platform matrix
|
||||
# layer on later, step by step.
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
@@ -39,6 +40,8 @@ jobs:
|
||||
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: fmt (check, stock defaults)
|
||||
run: nix develop -c cargo fmt --check
|
||||
- name: clippy (warnings denied)
|
||||
run: nix develop -c cargo clippy --all-targets -- -D warnings
|
||||
- name: test
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
# Matrix (D1, cross-built from Linux x86_64 via cargo-zigbuild):
|
||||
# x86_64-unknown-linux-musl aarch64-unknown-linux-musl (static, D2)
|
||||
# x86_64-pc-windows-gnu aarch64-pc-windows-gnullvm (standalone .exe)
|
||||
# macOS is deferred — its arboard/AppKit link needs Apple's SDK (see ADR-ci-001).
|
||||
# D3 package-manager manifests layer on later.
|
||||
# The two macOS targets are built separately by the dispatched
|
||||
# release-macos.yaml (native Tart runner; ADR-ci-003 amendment), uploading to
|
||||
# the same release. D3 package-manager manifests layer on later.
|
||||
#
|
||||
# Tests run once (host) before the matrix, so a tag can never publish untested
|
||||
# code, even one pointing at a commit that was never gated on a branch.
|
||||
# code, even one pointing at a commit that was never gated on a branch. The
|
||||
# version guard (ADR-0054) refuses to publish a tag whose vX.Y.Z disagrees with
|
||||
# Cargo.toml's version, keeping `--version`, the release name, and the asset in
|
||||
# lockstep.
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
@@ -23,6 +27,27 @@ jobs:
|
||||
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: version guard — tag must equal Cargo.toml version (ADR-0054)
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Read the [package] version straight from Cargo.toml — toolchain-free
|
||||
# and robust. (An earlier `nix develop -c cargo metadata | node` version
|
||||
# broke: the flake devShell prints a banner to stdout, corrupting the
|
||||
# JSON pipe.) `^version = ` is anchored, so it matches only the package
|
||||
# version, never the `version = ` inside dependency inline tables.
|
||||
VER=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/^version = "(.*)"/\1/')
|
||||
echo "tag=$TAG cargo=v$VER"
|
||||
if [ -z "$VER" ]; then
|
||||
echo "ERROR: could not read the package version from Cargo.toml" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$TAG" != "v$VER" ]; then
|
||||
echo "ERROR: release tag '$TAG' != 'v$VER' (Cargo.toml). Bump Cargo.toml to the release version, commit, then retag (ADR-0054)." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: test
|
||||
run: nix develop -c cargo test --no-fail-fast
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@
|
||||
*.snap.new
|
||||
*.pending-snap
|
||||
|
||||
# Website tooling — Cloudflare Wrangler local cache/state (regenerable;
|
||||
# CI deploys from website/, this dir only appears on a local wrangler run)
|
||||
.wrangler/
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
# Astro/template-seeded editor configs we don't track (e.g. website/.vscode)
|
||||
.vscode/
|
||||
|
||||
@@ -108,10 +108,11 @@ Current decisions at a glance (each backed by an ADR):
|
||||
SQL `select` / `with` / `insert` / `update` / `delete`
|
||||
(ADR-0039). `EXPLAIN QUERY PLAN` never executes, so
|
||||
explaining a destructive command is safe.
|
||||
- **Continuous integration & release** (built on the `ci` branch,
|
||||
2026-06-15; decisions in `docs/ci/adr/` — **ADR-ci-001/002/003**,
|
||||
a namespace kept separate from the main ADR sequence to avoid
|
||||
cross-branch number collisions, like the website's): a self-hosted
|
||||
- **Continuous integration & release** (developed on the `ci` branch,
|
||||
**merged to `main` 2026-06-15**; decisions in `docs/ci/adr/` —
|
||||
**ADR-ci-001/002/003**, a namespace kept separate from the main ADR
|
||||
sequence to avoid cross-branch number collisions, like the website's):
|
||||
a self-hosted
|
||||
**Gitea Actions** pipeline built on a **nix flake** (pinned Rust
|
||||
`1.95.0` — one source of toolchain for dev *and* CI) plus a
|
||||
prebuilt CI image. **Gate** (`ci.yaml`): `clippy -D warnings` +
|
||||
@@ -122,44 +123,72 @@ Current decisions at a glance (each backed by an ADR):
|
||||
`release-macos.yaml` on a Tart Apple-Silicon runner (de-nix the
|
||||
`libiconv` load path + ad-hoc re-sign). All published to a Gitea
|
||||
release with `.sha256`s. **`fmt` is intentionally not gated yet**
|
||||
(the tree isn't stock-`rustfmt`-clean). `workflow_dispatch` is
|
||||
Gitea-default-branch-only, so `release-macos` is dispatchable once
|
||||
this lands on `main`.
|
||||
(the tree isn't stock-`rustfmt`-clean). Now that this is on `main`,
|
||||
`release-macos` is dispatchable (`workflow_dispatch` is
|
||||
Gitea-default-branch-only) — **dispatched and verified working**: the
|
||||
macOS build + de-nix/re-sign + upload runs end-to-end and the binaries
|
||||
launch.
|
||||
- **Website & docs site** (developed on the `website` branch, **merged
|
||||
to `main`**; the branch **stays open** for future staging deploys;
|
||||
decisions in `docs/website/adr/` — **ADR-website-001**, its own
|
||||
namespace like CI's): an **Astro + Starlight + Tailwind** marketing
|
||||
landing page plus the **canonical** user docs, under `website/`.
|
||||
Showcase demos are **asciinema casts** (a scripted-input driver, paced
|
||||
+ re-recordable — *not* `history.log` replay; the `--demo` overlay,
|
||||
ADR-0047, dresses them). **Deployed to Cloudflare Pages via Gitea
|
||||
Actions** (`.gitea/workflows/website.yaml`; the crate gate is skipped
|
||||
for website-only changes). Two copy rules bind user-facing text: **no
|
||||
engine name** (continues ADR-0002) and **no "DSL"** (say "simple mode"
|
||||
/ "advanced mode"). Install docs are still partial — package-manager
|
||||
manifests + some installation instructions are pending (`requirements.md`
|
||||
D3 / DOC1).
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
.
|
||||
├── Cargo.toml # dependencies, lints (nursery)
|
||||
├── Cargo.toml / Cargo.lock # dependencies, lints (nursery)
|
||||
├── CLAUDE.md # this file
|
||||
├── flake.nix / flake.lock # pinned Rust toolchain, one source for dev + CI (ADR-ci-002)
|
||||
├── rust-toolchain.toml # toolchain pin
|
||||
├── .gitea/ # Gitea Actions workflows + prebuilt CI image (ADR-ci-001..003, website)
|
||||
├── ci/ # CI build helpers (e.g. winstub/ — Windows link stub)
|
||||
├── docs/
|
||||
│ ├── adr/ # all decision records (read 0000 first)
|
||||
│ ├── adr/ # project-wide decision records (read 0000 first)
|
||||
│ ├── ci/{adr,handoff}/ # CI subproject ADRs (ci-001..003) + handoffs
|
||||
│ ├── website/{adr,plans}/ # website subproject ADRs (website-001) + plan
|
||||
│ ├── handoff/ # session-handover notes
|
||||
│ ├── plans/ # working plans
|
||||
│ └── requirements.md # the Phase-1 checklist with progress
|
||||
├── src/
|
||||
│ ├── action.rs # Action enum (Quit / ExecuteDsl)
|
||||
│ ├── action.rs # Action enum (Quit / ExecuteDsl / …)
|
||||
│ ├── app.rs # App state + pure update() + Tier-1 tests
|
||||
│ ├── cli.rs # CLI args (--theme, --log-file)
|
||||
│ ├── cli.rs # CLI args (--theme, --log-file, --demo, --no-undo, --resume, …)
|
||||
│ ├── clipboard.rs # copy output to the system clipboard
|
||||
│ ├── completion.rs # Tab completion + schema cache
|
||||
│ ├── db.rs # rusqlite worker, all DDL/DML, metadata tables
|
||||
│ ├── dsl/
|
||||
│ │ ├── action.rs # ReferentialAction enum + parsing
|
||||
│ │ ├── command.rs # Command AST + RelationshipSelector + RowFilter
|
||||
│ │ ├── mod.rs # re-exports
|
||||
│ │ ├── parser.rs # parse entry point → unified-grammar walker
|
||||
│ │ ├── shortid.rs # base58 generator + validator
|
||||
│ │ ├── types.rs # user-facing Type enum + fk_target_type
|
||||
│ │ └── value.rs # Value/Bound + per-type validation
|
||||
│ │ ├── grammar/ # hand-rolled unified grammar nodes (DSL + SQL)
|
||||
│ │ ├── walker/ # grammar walker (driver / context / highlight / outcome)
|
||||
│ │ ├── command.rs parser.rs types.rs value.rs action.rs shortid.rs sql_functions.rs
|
||||
│ ├── echo.rs # command echo / SQL rendering
|
||||
│ ├── event.rs # AppEvent (input + DSL outcomes)
|
||||
│ ├── lib.rs # module re-exports for tests
|
||||
│ ├── logging.rs # tracing setup, file-backed
|
||||
│ ├── main.rs # binary entry; thin
|
||||
│ ├── mode.rs # Simple/Advanced mode enum
|
||||
│ ├── runtime.rs # Tokio loop, terminal setup, dispatch
|
||||
│ ├── friendly/ # friendly-error layer + string catalog (strings/en-US.yaml) + keys
|
||||
│ ├── input_render.rs # input-field render + ambient hint classification
|
||||
│ ├── output_render.rs # output-panel render helpers (incl. relationship diagrams)
|
||||
│ ├── logging.rs main.rs mode.rs runtime.rs # tracing / entry / mode enum / Tokio loop
|
||||
│ ├── persistence/ # csv + yaml + history IO + migrations
|
||||
│ ├── project/ # open/create, lock, naming, prettifier
|
||||
│ ├── seed/ # seed generators / heuristics / vocabulary (ADR-0048)
|
||||
│ ├── snapshots/ # insta snapshots for Tier-2 tests
|
||||
│ ├── theme.rs # light/dark themes
|
||||
│ └── ui.rs # ratatui rendering
|
||||
└── tests/
|
||||
└── walking_skeleton.rs # Tier-3 integration tests
|
||||
│ ├── theme.rs type_change.rs ui.rs undo.rs # themes / column type-change / render / undo ring
|
||||
│ └── lib.rs # module re-exports for tests
|
||||
├── tests/
|
||||
│ ├── it/ # Tier-3 integration tests (consolidated into one binary)
|
||||
│ └── typing_surface_matrix.rs # typing-surface matrix (separate Tier-3 target)
|
||||
└── website/ # Astro + Starlight docs/marketing site (ADR-website-001)
|
||||
├── src/ public/ casts-src/ # pages + assets + asciinema cast sources
|
||||
└── astro.config.mjs package.json … # deploys to Cloudflare Pages via Gitea Actions
|
||||
```
|
||||
|
||||
Key invariants in the code:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Contributing to rdbms-playground
|
||||
|
||||
Contributions are welcome — bug reports, ideas, and pull requests. The
|
||||
project lives on Gitea at
|
||||
<https://git.lazyeval.net/oli/rdbms-playground>; please file issues and
|
||||
open pull requests there. It's approaching its first public release, so
|
||||
the most useful contributions right now are bug reports and rough edges
|
||||
you hit while learning.
|
||||
|
||||
## License of contributions
|
||||
|
||||
Unless you explicitly state otherwise, any contribution you intentionally
|
||||
submit for inclusion in this project — as defined in the Apache-2.0
|
||||
license — shall be **dual-licensed under `MIT OR Apache-2.0`** (the
|
||||
project's licenses), without any additional terms or conditions.
|
||||
|
||||
This is the standard Rust "inbound = outbound" arrangement: your
|
||||
contribution is offered under the same licenses the project distributes
|
||||
under, so — via Apache-2.0 §5 — it carries the Apache-2.0 §3 patent grant
|
||||
to all users. No separate CLA is required.
|
||||
Generated
+1
-1
@@ -1535,7 +1535,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rdbms-playground"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
+41
-2
@@ -1,12 +1,21 @@
|
||||
[package]
|
||||
name = "rdbms-playground"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
description = "A cross-platform TUI playground for learning relational databases."
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://git.lazyeval.net/oli/rdbms-playground"
|
||||
homepage = "https://relplay.org"
|
||||
readme = "README.md"
|
||||
publish = false
|
||||
keywords = ["database", "sql", "tui", "learning", "playground"]
|
||||
categories = ["command-line-utilities", "database"]
|
||||
# Keep the published crate to the code that builds the binary — the website,
|
||||
# decision records, and CI plumbing are repo-only (ADR-0056).
|
||||
exclude = ["/website", "/docs", "/.gitea", "/.codegraph"]
|
||||
# `publish = false` removed (ADR-0056): the crate is intended for
|
||||
# crates.io. The actual `cargo publish` is a deliberate, irreversible
|
||||
# maintainer step (needs the crates.io token) — do it at a tagged release
|
||||
# whose assets the binstall metadata below points at.
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.102"
|
||||
@@ -85,3 +94,33 @@ nursery = { level = "warn", priority = -1 }
|
||||
module_name_repetitions = "allow"
|
||||
missing_errors_doc = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
|
||||
# cargo-binstall (ADR-0056): let `cargo binstall rdbms-playground` fetch the
|
||||
# prebuilt release binary instead of compiling from source. Our release assets
|
||||
# are BARE binaries (no archive) named `rdbms-playground-v<version>-<target>`
|
||||
# (`.exe` on Windows) with `.sha256` sidecars (ADR-ci-003), so `pkg-fmt = "bin"`.
|
||||
# `{ version }` excludes the leading `v`, so the template spells `v{ version }`.
|
||||
#
|
||||
# Target mapping: macOS host triples match our asset triples directly. But we
|
||||
# ship the fully-static *-linux-MUSL build (glibc hosts are *-linux-gnu) and
|
||||
# *-windows-GNU/GNULLVM (most Windows hosts are *-msvc), so those common host
|
||||
# triples need explicit overrides pointing at the asset we actually publish.
|
||||
#
|
||||
# NOTE: unverified against a real `cargo binstall` run (binstall isn't a dep and
|
||||
# nothing is on crates.io yet) — validate at the first publish + matching release.
|
||||
[package.metadata.binstall]
|
||||
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-{ target }{ archive-suffix }"
|
||||
pkg-fmt = "bin"
|
||||
bin-dir = "{ bin }{ binary-ext }"
|
||||
|
||||
[package.metadata.binstall.overrides.x86_64-unknown-linux-gnu]
|
||||
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-unknown-linux-musl"
|
||||
|
||||
[package.metadata.binstall.overrides.aarch64-unknown-linux-gnu]
|
||||
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-aarch64-unknown-linux-musl"
|
||||
|
||||
[package.metadata.binstall.overrides.x86_64-pc-windows-msvc]
|
||||
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-pc-windows-gnu.exe"
|
||||
|
||||
[package.metadata.binstall.overrides.aarch64-pc-windows-msvc]
|
||||
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-aarch64-pc-windows-gnullvm.exe"
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Lazy Evaluation Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Lazy Evaluation Ltd
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,98 @@
|
||||
# rdbms-playground
|
||||
|
||||
A cross-platform terminal app for **learning relational database
|
||||
concepts** — tables, columns, primary and foreign keys, relationships,
|
||||
indexes, queries, and query plans — in a safe sandbox.
|
||||
|
||||
It's a teaching tool, not a database administration tool. It meets
|
||||
beginners with guided, keyword-based commands (**simple mode**) and grows
|
||||
with them to raw SQL (**advanced mode**), so the same playground works
|
||||
from "what is a primary key?" through to writing real queries and reading
|
||||
their execution plans.
|
||||
|
||||
Website & documentation: **<https://relplay.org>**
|
||||
|
||||
## Install
|
||||
|
||||
### One line (Linux / macOS)
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Detects your OS and CPU, downloads the matching release binary, verifies
|
||||
its SHA-256 checksum, and installs it to `~/.local/bin`. Set
|
||||
`RDBMS_INSTALL_DIR` to install elsewhere, or `RDBMS_VERSION=vX.Y.Z` to
|
||||
pin a version. (Prefer to read before you run? The script lives at
|
||||
`scripts/install.sh`.)
|
||||
|
||||
### One line (Windows, PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Downloads the matching `.exe`, verifies its checksum, installs to
|
||||
`%LOCALAPPDATA%\Programs\rdbms-playground`, and adds it to your user
|
||||
PATH. Or use a package manager (Scoop / winget) once those land.
|
||||
|
||||
### With `cargo binstall`
|
||||
|
||||
If you have [`cargo-binstall`](https://github.com/cargo-bins/cargo-binstall)
|
||||
(install it first — it is not part of `cargo` itself):
|
||||
|
||||
```sh
|
||||
cargo binstall rdbms-playground
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
```sh
|
||||
cargo install rdbms-playground # from crates.io
|
||||
# or, from a clone:
|
||||
cargo build --release # binary at target/release/rdbms-playground
|
||||
```
|
||||
|
||||
### Prebuilt binaries
|
||||
|
||||
Every release publishes static Linux, standalone Windows, and macOS
|
||||
binaries (x86_64 and aarch64) with `.sha256` checksums on the
|
||||
[releases page](https://git.lazyeval.net/oli/rdbms-playground/releases).
|
||||
Windows users can also use the binary directly (package-manager support
|
||||
is planned).
|
||||
|
||||
## A quick taste
|
||||
|
||||
```
|
||||
create table Customers with pk id(serial)
|
||||
add column Customers: name (text)
|
||||
add column Customers: email (text)
|
||||
insert into Customers values ('Ann', 'ann@example.io')
|
||||
show data Customers
|
||||
```
|
||||
|
||||
Press **F1** while typing for a contextual hint about the command you're
|
||||
building, or type `help` for the full command list. Switch to raw SQL
|
||||
with `mode advanced` (or prefix a single line with `:`).
|
||||
|
||||
## Project status
|
||||
|
||||
Approaching its first public release. See the website for current
|
||||
features; installation via package managers (Homebrew, Scoop, winget) is
|
||||
on the roadmap.
|
||||
|
||||
## License
|
||||
|
||||
Dual-licensed under either of
|
||||
|
||||
- MIT license ([LICENSE-MIT](LICENSE-MIT))
|
||||
- Apache License 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
|
||||
|
||||
at your option.
|
||||
|
||||
## Contribution
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally
|
||||
submitted for inclusion in the work by you, as defined in the Apache-2.0
|
||||
license, shall be dual-licensed as above, without any additional terms or
|
||||
conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
@@ -0,0 +1,73 @@
|
||||
# ADR-0054: Release versioning policy + version surfaces (`--version` / `version`)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — **implemented 2026-06-16** (plan:
|
||||
`docs/plans/20260616-public-availability.md`, step 1). First step on the
|
||||
road to public availability. Adds a `--version` / `-V` CLI flag and an
|
||||
in-app `version` command, both reporting `CARGO_PKG_VERSION`, plus a
|
||||
release-CI guard that the `v*` tag equals that version. No prior issue or
|
||||
`requirements.md` item existed for this — it was an untracked gap.
|
||||
|
||||
## Context
|
||||
|
||||
Before this, `Cargo.toml` carried `version = "0.1.0"`, the binary exposed
|
||||
**no** way to report its version, and `release.yaml` named assets from the
|
||||
**git tag** (`rdbms-playground-<tag>-<target>`) while building from
|
||||
`Cargo.toml`. Tag and crate version were **decoupled**: tagging `v0.2.0`
|
||||
would publish an asset named `…-v0.2.0-…` containing a binary that (had it
|
||||
been able to say) reported `0.1.0`. On the way to public availability —
|
||||
where users download a versioned artifact and file bug reports against "the
|
||||
version I'm running" — that drift is a correctness problem.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **`Cargo.toml` `version` is the single source of truth.** This is the
|
||||
idiomatic Rust position and avoids making `Cargo.toml` lie. The version
|
||||
is read at compile time via `env!("CARGO_PKG_VERSION")`; no build-time
|
||||
injection of the tag into the crate.
|
||||
|
||||
2. **Two user-facing surfaces, one source:**
|
||||
- **`--version` / `-V`** — CLI flag (hand-rolled parser, mirrors
|
||||
`--help`): prints and exits before any other work (`main.rs`).
|
||||
- **`version`** — an in-app app command (REGISTRY node `app::VERSION`,
|
||||
`AppCommand::Version`), emitting the same line into the output panel.
|
||||
Both go through `cli::version_text()` → the catalog key
|
||||
`cli.version_line` (`"rdbms-playground {version}"`), so there is exactly
|
||||
one rendered string and one version source.
|
||||
|
||||
3. **Release-CI discipline.** `release.yaml`'s pre-build `test` job gains a
|
||||
**version guard**: it reads the `[package]` version directly from
|
||||
`Cargo.toml` (`grep -m1 '^version = '` — toolchain-free; an earlier
|
||||
`cargo metadata | node` form broke on the flake devShell's stdout banner)
|
||||
and **fails the release** unless the pushed tag equals `v<that version>`.
|
||||
So `--version`, the release name, and the downloaded asset are always in
|
||||
lockstep — enforced by the machine, not by memory.
|
||||
|
||||
4. **The release ritual:** bump `Cargo.toml` → commit → tag `v<that
|
||||
number>` → push the tag. The guard rejects any deviation.
|
||||
|
||||
### Rejected / deferred
|
||||
- **Inject the tag into the build** (tag as source of truth): fiddly with
|
||||
cargo and makes `Cargo.toml` a placeholder/lie. Rejected.
|
||||
- **Git-hash + build-date enrichment** (a `build.rs` so dev builds read
|
||||
`0.2.0 (a1b2c3d)`): useful for bug reports, but not needed for the
|
||||
tag↔release↔`--version` consistency this ADR is about. Deferred; can be
|
||||
added behind the same `version_text()` seam without changing the policy.
|
||||
- **UI placement beyond the `version` command** (status-bar string, etc.):
|
||||
the command + `help` listing is enough for now (user decision).
|
||||
|
||||
## Consequences
|
||||
|
||||
- A release can no longer ship a binary whose self-reported version
|
||||
disagrees with its tag/filename.
|
||||
- Cutting a release now *requires* a `Cargo.toml` bump commit — a small,
|
||||
deliberate step (and a natural place to update a changelog later).
|
||||
- New keys: `cli.version_line` (+ `help.app.version`, `parse.usage.version`,
|
||||
`hint.cmd.version.what`/`.example`); a new REGISTRY command means the
|
||||
comprehensiveness coverage test now also requires `hint.cmd.version`,
|
||||
which is supplied. Tested: CLI flag parse (`--version`/`-V`/default-off),
|
||||
`version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses to
|
||||
`AppCommand::Version` and emits the version.
|
||||
- This is step 1 of `docs/plans/20260616-public-availability.md`; the
|
||||
installer (`install.sh`) and package-manager work (D3) build on top.
|
||||
@@ -0,0 +1,86 @@
|
||||
# ADR-0055: `curl | sh` install script (`scripts/install.sh`)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — **implemented 2026-06-17** (plan:
|
||||
`docs/plans/20260616-public-availability.md`, step 2). Step 2 on the road
|
||||
to public availability, building on ADR-0054 (versioned releases) and
|
||||
ADR-ci-001/003 (the Gitea releases it downloads from). Tracked by the
|
||||
plan + this ADR (no Gitea issue — user decision, 2026-06-17).
|
||||
|
||||
## Context
|
||||
|
||||
Until now, installing meant: find the releases page, work out which of
|
||||
the six assets matches your machine, download it, `chmod +x`, move it
|
||||
onto `PATH`, and (on macOS) wonder about Gatekeeper. That is too much
|
||||
friction for a teaching tool aimed at beginners. The Gitea releases are
|
||||
**publicly downloadable** (confirmed), with deterministic asset names
|
||||
(`rdbms-playground-<tag>-<target>[.exe]`) and `.sha256` sidecars
|
||||
(ADR-ci-003), and a `releases/latest` API — enough to script a one-liner
|
||||
install.
|
||||
|
||||
## Decision
|
||||
|
||||
Ship **`scripts/install.sh`**, run as
|
||||
`curl -fsSL <gitea-raw>/scripts/install.sh | sh`:
|
||||
|
||||
- **POSIX `sh`** (no bashisms) — it runs under the `sh` of `curl | sh`;
|
||||
kept **shellcheck-clean** (`-s sh`).
|
||||
- **Platform detection** from `uname` → target triple: Linux →
|
||||
`<arch>-unknown-linux-musl` (the fully-static build — one universal
|
||||
Linux artifact, no glibc/version coupling), macOS → `<arch>-apple-darwin`;
|
||||
`x86_64`/`amd64` and `aarch64`/`arm64` both map. **Windows is rejected**
|
||||
with a pointer to Scoop/winget/the releases page (the binary is a `.exe`,
|
||||
not a `curl|sh` target).
|
||||
- **Version:** the `releases/latest` API tag by default; `RDBMS_VERSION`
|
||||
pins a specific tag.
|
||||
- **Integrity:** always download the `.sha256` sidecar and **verify**
|
||||
(`sha256sum`/`shasum -a 256`); a mismatch aborts the install. HTTPS only.
|
||||
- **Install location:** `~/.local/bin` by default (user-writable, no
|
||||
sudo), overridable via `RDBMS_INSTALL_DIR`; prints a PATH hint if the
|
||||
dir isn't on `PATH`.
|
||||
- **macOS note:** a `curl` download is **not** Gatekeeper-quarantined, so
|
||||
the binary runs as-is even while it is only ad-hoc-signed; proper
|
||||
Developer-ID signing + notarization (for *browser* downloads) is a
|
||||
separate, postponed task (see the plan's signing item).
|
||||
- **Testing seams:** `RDBMS_OS`/`RDBMS_ARCH` force detection and
|
||||
`--print-target` prints the resolved triple and exits — so the mapping
|
||||
is checkable without a download.
|
||||
|
||||
### Rejected / deferred
|
||||
- **Hosting the script on the website domain** (Cloudflare): nicer URL,
|
||||
but adds a moving part; the **Gitea repo raw URL** is simplest and the
|
||||
binaries live there anyway (user decision). The website may later
|
||||
*reference* the same command.
|
||||
- **Uploading `install.sh` as a release asset** for a stable link:
|
||||
optional; the branch raw URL is fine for now.
|
||||
|
||||
## Amendment 1 — `install.ps1` (Windows) added (2026-06-17)
|
||||
|
||||
Windows was originally deferred to Scoop/winget; the user opted for **both**
|
||||
a PowerShell one-liner now *and* package managers later. Added
|
||||
**`scripts/install.ps1`** (`irm <url> | iex`): maps the host CPU to our
|
||||
`*-windows-gnu`/`-gnullvm` `.exe`, resolves the latest release (or
|
||||
`-Version`/`RDBMS_VERSION`), downloads + **SHA-256-verifies**, installs to
|
||||
`%LOCALAPPDATA%\Programs\rdbms-playground` (`-InstallDir`/`RDBMS_INSTALL_DIR`
|
||||
override), and adds that dir to the **user PATH**. **Caveat:** unlike
|
||||
`install.sh` (verified end-to-end), this was **written but not tested from
|
||||
this environment** (no PowerShell available) — validate on a real Windows
|
||||
host. Scoop/winget (D3) remain the idiomatic package-manager routes.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A first-time user runs one line and gets a checksum-verified binary on
|
||||
`PATH`. The website's install copy (website branch, separate agent) can
|
||||
point at this command.
|
||||
- **Verified end-to-end** (2026-06-17) against the live public `v0.1.0`:
|
||||
all four Linux/macOS platform mappings + Windows/unknown-arch rejection;
|
||||
pinned and latest paths; checksum verification incl. a tamper-rejection
|
||||
check; install + run on Linux x86_64. (The installed `v0.1.0` predates
|
||||
`--version`, ADR-0054 — a non-issue, and the reason to cut a new
|
||||
release.)
|
||||
- **No automated regression guard in CI yet:** shellcheck isn't in the
|
||||
flake, and there's no shell-test harness here (no bats). Recommended
|
||||
follow-up: add a `shellcheck scripts/*.sh` gate (touches ADR-ci-002 —
|
||||
needs shellcheck in the devShell). For now the guard is local
|
||||
shellcheck + the documented end-to-end verification.
|
||||
@@ -0,0 +1,88 @@
|
||||
# ADR-0056: crates.io publish-readiness + `cargo binstall` metadata (D3)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — **prepared 2026-06-17** (plan:
|
||||
`docs/plans/20260616-public-availability.md`, step 3a). The crate is made
|
||||
**ready to publish** and carries `cargo-binstall` metadata. The actual
|
||||
`cargo publish` is a gated maintainer step (see Ordering). First D3
|
||||
package-manager mechanism; builds on ADR-0054 (versioned releases),
|
||||
ADR-0055 (installer), ADR-ci-003 (release assets). Tracked by plan + ADR
|
||||
(no Gitea issue — user decision).
|
||||
|
||||
## Context
|
||||
|
||||
`cargo binstall rdbms-playground` (and `cargo install`) need the crate on
|
||||
**crates.io** (user decision, 2026-06-17). The manifest had
|
||||
`publish = false`, a `readme = "README.md"` pointing at a **missing**
|
||||
file, and no `keywords`/`categories`. Our release assets are **bare
|
||||
binaries** (not archives) named `rdbms-playground-v<version>-<target>`
|
||||
(`.exe` on Windows) with `.sha256` sidecars (ADR-ci-003); critically the
|
||||
**release target triples differ from users' host triples** — we ship the
|
||||
static `*-linux-musl` build (hosts are `*-linux-gnu`) and
|
||||
`*-windows-gnu`/`-gnullvm` (hosts are `*-msvc`); only macOS matches.
|
||||
|
||||
## Decision
|
||||
|
||||
**Publish-readiness (this change):**
|
||||
- Drop `publish = false`; add `homepage = "https://relplay.org"`,
|
||||
`keywords`, `categories = ["command-line-utilities", "database"]`, and
|
||||
an `exclude` (`/website`, `/docs`, `/.gitea`, `/.codegraph`) so the
|
||||
published crate is code-only (585 files/8.3 MiB → 353/913 KiB
|
||||
compressed).
|
||||
- Author **`README.md`** (the `readme` target + crates.io front page;
|
||||
engine-neutral and "simple/advanced mode" wording per ADR-0002 / the
|
||||
website copy rules), with install instructions (curl|sh, binstall,
|
||||
source, prebuilt).
|
||||
- Add **`LICENSE-MIT`** and **`LICENSE-APACHE`** (the latter the verbatim
|
||||
canonical text, added by the maintainer; both © Lazy Evaluation Ltd —
|
||||
the publication entity), and a **`CONTRIBUTING.md`** stating the
|
||||
"inbound = outbound" dual-license arrangement (so Apache-2.0 §5 makes
|
||||
the §3 patent grant explicit on the self-hosted forge). Dual license
|
||||
kept (not MIT-only) — user decision after reviewing the patent-grant
|
||||
rationale.
|
||||
|
||||
**`cargo binstall` metadata** (`[package.metadata.binstall]`, syntax
|
||||
verified against cargo-binstall SUPPORT.md):
|
||||
- `pkg-fmt = "bin"` (bare binary), `bin-dir = "{ bin }{ binary-ext }"`,
|
||||
and a base `pkg-url` using `v{ version }` (the `{ version }` placeholder
|
||||
excludes the leading `v`).
|
||||
- **Per-target `overrides`** mapping the common host triples to the asset
|
||||
we actually publish: `x86_64`/`aarch64-unknown-linux-gnu` → the `-musl`
|
||||
asset; `x86_64`/`aarch64-pc-windows-msvc` → the `-gnu`/`-gnullvm`
|
||||
`.exe`. macOS needs no override (host triple == asset triple). The docs
|
||||
do **not** promise automatic musl/gnu or msvc/gnu fallback, hence
|
||||
explicit overrides.
|
||||
|
||||
**Ordering / gating (important):**
|
||||
- `cargo publish` is **irreversible** (needs the crates.io token; a
|
||||
version can't be un-published, only yanked) — a deliberate **maintainer
|
||||
step**, not done here.
|
||||
- binstall's `pkg-url` resolves to a **tagged release's** assets, so
|
||||
publish **at a new tagged version whose release already exists**, and
|
||||
publish **after** that release is built. **Do not publish `0.1.0`** — it
|
||||
would diverge from the already-released `0.1.0` binaries (which predate
|
||||
`--version`, ADR-0054). The clean path: bump → tag → release builds →
|
||||
`cargo publish`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo publish --dry-run --allow-dirty` packages + verify-builds cleanly
|
||||
(353 files, 913 KiB compressed; no metadata errors).
|
||||
- `cargo metadata` confirms the `binstall` block + all four `overrides`
|
||||
parse.
|
||||
- **Unverified:** an actual `cargo binstall` run — cargo-binstall isn't a
|
||||
dependency and nothing is on crates.io yet. **Validate at the first
|
||||
publish + matching release** (especially the windows-msvc→gnu and
|
||||
linux-gnu→musl overrides).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The crate can be published at the next tagged release with `cargo
|
||||
publish` (+ the token); `cargo install rdbms-playground` and `cargo
|
||||
binstall rdbms-playground` then work.
|
||||
- Remaining D3: Scoop, Homebrew (`lazyeval` tap), winget (komac/manual) —
|
||||
each a manifest + a per-release bump, tracked in the plan.
|
||||
- Remaining follow-up: run the real `cargo binstall` validation at the
|
||||
first publish + matching release (the license files, © holder, and
|
||||
CONTRIBUTING are now in place).
|
||||
@@ -66,3 +66,6 @@ This directory contains the project's ADRs, recorded per
|
||||
- [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget)
|
||||
- [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec<String>`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*)
|
||||
- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implemented 2026-06-15** (Phases A–D; closes **A1** + requirements **H2**). Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help <topic>` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.<hint_id>` (per command form) and `hint.err.<class>` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.<topic>`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Runtime `translate_error` classes resolve via stored `last_error_hint_key` (`hint` command / empty-F1). (The second route — pre-submit `diagnostic.*` read live from the walker on the F1 path — is **deferred**, issue **#38**: `Diagnostic` carries no class key.) Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): v1 scope = ~37 command forms + 9 runtime error classes (comprehensive for those, ~57 blocks), authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test**, with graceful fall-back to tier-2 if a key is ever missing. The **pre-submit-diagnostic route + ~33 `diagnostic.*` blocks were deferred** (issue **#38**) — `Diagnostic` carries no class key, so the route needs a broad change for marginal value (tier-2 already surfaces diagnostics; many duplicate runtime classes). Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive-for-commands-and-errors scope; exemplars-first; diagnostics deferred. OOS: per-topic `hint <topic>` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); clause-concept hints (issue #37); the diagnostic route (issue #38); the `help`-side advanced-SQL gap (issue #36)
|
||||
- [ADR-0054 — Release versioning policy + version surfaces (`--version` / `version`)](0054-release-versioning-and-version-surfaces.md) — **Accepted + implemented 2026-06-16** (plan: `docs/plans/20260616-public-availability.md`, step 1 on the road to public availability; no prior issue/`requirements.md` item — an untracked gap). Fixes the **tag↔crate-version decoupling**: `Cargo.toml` built `0.1.0` while `release.yaml` named assets from the git tag, so a binary could report a version different from the asset it shipped in. **Decision:** `Cargo.toml` `version` is the **single source of truth** (read via `env!("CARGO_PKG_VERSION")`, no tag-injection); two surfaces report it through one `cli::version_text()` → catalog `cli.version_line` — a **`--version` / `-V`** CLI flag (mirrors `--help`, prints+exits in `main.rs`) and an in-app **`version`** command (REGISTRY node `app::VERSION`, `AppCommand::Version`, emits via `note_system`); and a **release-CI version guard** (`release.yaml` `test` job reads the `[package]` version from `Cargo.toml` and **fails the release** unless the `v*` tag equals `v<version>`; the guard's parse was later switched from `cargo metadata | node` to a `grep` on Cargo.toml after the former broke on the flake devShell's stdout banner). Release ritual: bump `Cargo.toml` → commit → tag → push. New keys `cli.version_line` + `help.app.version` + `parse.usage.version` + `hint.cmd.version.{what,example}` (the new REGISTRY command pulls in the comprehensiveness coverage gate). Rejected: tag-as-source (makes Cargo.toml lie). Deferred: git-hash/build-date enrichment (behind the same `version_text()` seam); UI placement beyond the command. Tested test-first: CLI parse (`--version`/`-V`/default-off), `version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses + emits. Also corrected a stale `release.yaml` header comment ("macOS is deferred" → built by the dispatched `release-macos.yaml`).
|
||||
- [ADR-0055 — `curl | sh` install script (`scripts/install.sh`)](0055-curl-sh-install-script.md) — **Accepted + implemented 2026-06-17** (plan: `docs/plans/20260616-public-availability.md`, step 2; tracked by plan + ADR, no Gitea issue — user decision). A one-line installer (`curl -fsSL <gitea-raw>/scripts/install.sh | sh`) so beginners don't hand-pick an asset + `chmod +x`. **POSIX `sh`** (shellcheck-clean), detects `uname` OS/arch → target triple (**Linux → the fully-static `*-musl`** build, macOS → `*-apple-darwin`; `amd64`/`arm64` aliased; **Windows rejected** → Scoop/winget/releases page), resolves the version from the **`releases/latest`** API (or `RDBMS_VERSION` to pin), downloads the asset **and its `.sha256` and verifies it** (mismatch aborts), installs to `~/.local/bin` (`RDBMS_INSTALL_DIR` override) with a PATH hint. Testing seams: `RDBMS_OS`/`RDBMS_ARCH` + `--print-target`. macOS note: `curl` downloads aren't Gatekeeper-quarantined so the ad-hoc binary runs as-is (Developer-ID + notarization is the postponed signing task). **Verified end-to-end against the live public `v0.1.0`** (all platform mappings, pinned + latest, checksum incl. tamper-rejection, install + run). Rejected: website-domain hosting (extra moving part; Gitea raw is simplest); deferred: uploading the script as a release asset, and a **shellcheck CI gate** (shellcheck isn't in the flake — touches ADR-ci-002). **Amendment 1 (2026-06-17):** added a Windows **`scripts/install.ps1`** (`irm | iex`; maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`, SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH) — user chose both a one-liner *and* Scoop/winget; **written but untested from this env** (no PowerShell — validate on Windows).
|
||||
- [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc` → `*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation.
|
||||
|
||||
@@ -133,3 +133,13 @@ declaration of the dev *and* build environment.
|
||||
flake for `requirements.md` **TT5** (CI runs the tiers) and the
|
||||
**D1/D2/D3** distribution items (the release uses a static musl target
|
||||
built through this flake).
|
||||
|
||||
## Amendment 1 — 2026-06-17: `fmt` gate enabled (issue #35)
|
||||
|
||||
The deferred "revisit on `main`" is done. With the CI + website branches
|
||||
merged and before the first public release, the tree was reformatted once
|
||||
with **stock `cargo fmt`** (no `rustfmt.toml` — stable rustfmt supports no
|
||||
meaningful customisation, and the pinned 1.95.0 toolchain makes
|
||||
`fmt --check` deterministic) in a single mechanical commit (`41b7e9a`,
|
||||
102 files, behaviour-preserving; recorded in `.git-blame-ignore-revs`).
|
||||
`ci.yaml`'s gate is now **`fmt --check` + clippy + test**. Closes **#35**.
|
||||
|
||||
@@ -20,6 +20,17 @@ This ADR records the **cross-platform build strategy**; it sits on top of
|
||||
**ADR-ci-002** (the nix flake, which now carries the cross toolchain) and
|
||||
**ADR-ci-001** (the pipeline, whose release job this fills in).
|
||||
|
||||
## Amendment 2 — 2026-06-16: CI on `main`; `release-macos` dispatched + verified
|
||||
|
||||
The CI branch is **merged to `main`**, so `release-macos.yaml`
|
||||
(`workflow_dispatch`, Gitea-default-branch-only) is now triggerable. It
|
||||
has been **dispatched and verified end-to-end**: both `*-apple-darwin`
|
||||
targets build on the Tart runner, the de-nix/re-sign step runs, the
|
||||
assets upload to the tagged release, and the binaries launch. macOS is
|
||||
therefore runtime-verified (alongside the original Linux x86_64 +
|
||||
Windows aarch64); only **Linux aarch64** and **Windows x86_64** remain
|
||||
link-clean / valid-format without a runtime smoke-test.
|
||||
|
||||
## Amendment — 2026-06-14: macOS implemented (closes D1)
|
||||
|
||||
macOS is no longer deferred. The two `*-apple-darwin` targets now build on a
|
||||
|
||||
@@ -19,5 +19,5 @@ here too).
|
||||
## Index
|
||||
|
||||
- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the original release job was Linux x86_64 only; it's now the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built via cargo-zigbuild — see **ADR-ci-003**. macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision).
|
||||
- [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard`→`x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible).
|
||||
- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner; becomes triggerable once CI is on `main`), de-nixes the binary's libiconv load path (`install_name_tool` → `/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). Runtime-verified by the user (2026-06-13): Linux x86_64 + Windows aarch64 run correctly; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release).
|
||||
- [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard`→`x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible). **Amendment 1 (2026-06-17, issue #35):** the deferred `fmt` gate is enabled — the tree was reformatted once with **stock `cargo fmt`** (no `rustfmt.toml`; pinned toolchain makes `fmt --check` deterministic) in a single mechanical commit (`41b7e9a`, 102 files, behaviour-preserving, in `.git-blame-ignore-revs`), and `ci.yaml`'s gate is now **`fmt --check` + clippy + test**.
|
||||
- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner), de-nixes the binary's libiconv load path (`install_name_tool` → `/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). **Amendment 2 (2026-06-16):** CI is **merged to `main`**, so `release-macos` is now triggerable (`workflow_dispatch` is default-branch-only) and has been **dispatched + verified end-to-end** (build → de-nix/re-sign → upload, binaries launch). Runtime-verified by the user: Linux x86_64, Windows aarch64, **and both macOS targets**; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release).
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Session handoff — 2026-06-16 (73)
|
||||
|
||||
Short, focused handover. Continues from handoff-72 (which completed the
|
||||
H2 hint-corpus verification pass). This session shipped one small
|
||||
feature — a **`Ctrl-G` demo-mode alias for F1** — plus follow-on doc
|
||||
hygiene. Commit `4016c3e`.
|
||||
|
||||
## §1. State
|
||||
|
||||
**Branch:** `main`, clean, all committed (local; **push pending** — your
|
||||
step; the backlog now spans the CI merge, H2, the hint-corpus fixes,
|
||||
handoffs 71/72/73, and this Ctrl-G commit). **2503 pass / 0 fail / 1
|
||||
ignored** (the long-standing `friendly` doctest), **clippy clean**
|
||||
(nursery, all targets). Open Gitea issues unchanged: **#35–#38**.
|
||||
|
||||
## §2. Why Ctrl-G (the problem)
|
||||
|
||||
Casts are recorded with **`autocast`** (ADR-0047 demo mode: `--demo` /
|
||||
`RDBMS_PLAYGROUND_DEMO`). The contextual hint overlay (ADR-0053 / H2)
|
||||
opens on **F1** — but F1 reaches the app only as a terminal **escape
|
||||
sequence** (`\eOP` / `\e[11~`), and **autocast cannot emit escape
|
||||
sequences**. So the single most teaching-relevant overlay was
|
||||
unreachable in recordings (and in presenter/teacher sessions, which also
|
||||
run `--demo`). Same wall that pushed step-captions onto `Ctrl+]` (a
|
||||
single control byte) rather than `Ctrl+!`.
|
||||
|
||||
### Chord choice — why Ctrl-G, why not Ctrl-1
|
||||
|
||||
The user's first instinct was `Ctrl-1` (mnemonic, near F1). **Not
|
||||
possible:** in a legacy terminal `Ctrl`+digit has no control byte —
|
||||
`Ctrl-1` arrives as a bare `1` (would type "1" into the buffer). The
|
||||
kitty keyboard protocol *would* encode it, but only as an escape
|
||||
sequence (the very thing autocast can't send), and this app deliberately
|
||||
does **not** push `KeyboardEnhancementFlags` (`runtime.rs` does only
|
||||
`enable_raw_mode` + `EnterAlternateScreen`). So the usable space is
|
||||
exactly **`Ctrl`+letter** (single legacy control bytes). After excluding
|
||||
taken chords (`Ctrl-C` quit, `Ctrl-O` nav, `Ctrl+]` caption,
|
||||
`Ctrl-A/E/W/K/U` readline per ADR-0049), byte-collisions
|
||||
(`Ctrl-H/I/J/M/[`), flow-control (`Ctrl-S/Q`), and likely-future
|
||||
(`Ctrl-R/P/N/Y/L/V`), **`Ctrl-G`** is the clean survivor (BEL/"abort" in
|
||||
line editors — nothing destructive to shadow).
|
||||
|
||||
## §3. What shipped (commit `4016c3e`)
|
||||
|
||||
ADR-0047 **Amendment 1**. **In demo mode only**, `Ctrl-G` aliases F1:
|
||||
|
||||
- Runs the *exact* F1 hint logic (`hint_key` guard in
|
||||
`App::handle_key`, `src/app.rs` ~line 1226 — `key.code == F(1) ||
|
||||
(self.demo_mode && Ctrl-G)`), so live-input → form hint, empty input →
|
||||
recent-error / getting-started.
|
||||
- **Badges as `[F1]`** (not `[CTRL-G]`): `demo_badge_label` maps
|
||||
`Ctrl-G → Some("[F1]")` (consulted only in demo mode — the caller
|
||||
gates). So a recorded cast is **visually identical to a real F1
|
||||
press**.
|
||||
- **Demo-gated:** the shipped keymap stays F1-only. Outside demo mode
|
||||
`Ctrl-G` falls through to the inert catch-all (the `Char(c)` insert arm
|
||||
excludes CONTROL, so no `g` is typed).
|
||||
- The keybinding strip (ADR-0051) is **not** changed — F1 stays the
|
||||
advertised key; `Ctrl-G` is a recorder aid and the badge already reads
|
||||
`[F1]`.
|
||||
|
||||
**Tests (test-first, `src/app.rs` Tier-1):** `ctrl_g_in_demo_mode_-
|
||||
aliases_f1_on_input`, `…_on_empty_input`, `ctrl_g_outside_demo_mode_-
|
||||
is_inert`, plus a `Ctrl-G → [F1]` assertion added to
|
||||
`demo_badge_label_maps_the_invisible_keys`. All confirmed red→green; the
|
||||
"inert" test passed on the pre-change code, proving the demo-gate.
|
||||
|
||||
### Using it in a cast
|
||||
With `--demo` active, send **`Ctrl-G`** in the autocast script wherever
|
||||
you want the hint overlay to appear; the viewer sees the `[F1]` badge.
|
||||
|
||||
## §4. Doc hygiene done alongside (same commit)
|
||||
|
||||
- **ADR-0047 file:** removed two stray `</content>` / `</invoke>` lines
|
||||
(tool-call residue accidentally committed when the ADR was authored).
|
||||
- **CLAUDE.md "Things deliberately deferred":** dropped three **stale**
|
||||
entries — **I1b** readline shortcuts (done, ADR-0049), **I3** tab
|
||||
completion, and **I4** syntax highlighting (both done; requirements.md
|
||||
even carries a 2026-06-07 reconciliation note that I3/I4 were
|
||||
"shipped but marked not yet"). Only **I1** (multi-line input) remains —
|
||||
it is genuinely still open (`[ ]` in requirements.md). *(This follows
|
||||
the handoff-72 cleanup that removed the equally-stale m:n/C4 +
|
||||
project-storage entries.)*
|
||||
|
||||
## §5. Open / next (unchanged from handoff-72 §5)
|
||||
|
||||
The hint corpus is trustworthy and the cast-F1 gap is closed. Roadmap:
|
||||
|
||||
1. **Push** (your step).
|
||||
2. **#35 (cargo fmt gate)** — precondition (CI merged) is met; the user
|
||||
wants it done once, before first publication. Needs a `rustfmt.toml`-
|
||||
vs-defaults decision first; tree is ~1800 hunks dirty.
|
||||
3. Other open `requirements.md` items: **I1** multi-line input, **I5/B3**
|
||||
in-flight cancellation, **TT4** PTY tier-4 (unwired), **DOC1**/**E2**
|
||||
user docs (partial), **TT5** Windows-execution + Tier-4-in-CI, **D3**
|
||||
packaging manifests. Design-first (`[~]`): **V4** session journal,
|
||||
**TU1** tutorial, **C3a**, **V3**.
|
||||
4. Hint follow-ups if wanted: **#36** `help` advanced-SQL, **#37** hint
|
||||
clause-concepts, **#38** hint diagnostic route.
|
||||
|
||||
## §6. How to take over
|
||||
|
||||
1. Read handoffs 71 → 72 → 73, `CLAUDE.md`, `docs/requirements.md`.
|
||||
2. Confirm green: `cargo test` (**2503 / 1 ignored**) + `cargo clippy
|
||||
--all-targets` (clean).
|
||||
3. For demo-mode / casting, read **ADR-0047** (+ its Amendment 1); for
|
||||
the hint overlay it aliases, **ADR-0053**.
|
||||
4. Workflow unchanged: phased, test-first, `/runda` + DA before commits,
|
||||
ADR amendment + README index-upkeep for decided-area changes, confirm
|
||||
commit messages with the user.
|
||||
5. Consider a `cargo sweep` at this milestone (`target/` grows; see
|
||||
CLAUDE.md "Build hygiene").
|
||||
@@ -0,0 +1,193 @@
|
||||
# Plan — road to public availability (versioning + install + packaging)
|
||||
|
||||
Status: **planning** (2026-06-16). Captures the decisions taken in
|
||||
discussion plus the open questions, so the work can proceed in steps.
|
||||
The versioning piece is being implemented first and is recorded as a
|
||||
decision in **ADR-0054**; the install-script and package-manager pieces
|
||||
are roadmap items here until each is built.
|
||||
|
||||
Scope note: this repo lives on the self-hosted Gitea at
|
||||
`git.lazyeval.net` (`oli/rdbms-playground`); releases publish there
|
||||
(ADR-ci-001/003). Publication branding uses the company name **Lazy
|
||||
Evaluation Ltd → `lazyeval`**, not the personal `oli` username, for
|
||||
anything user-facing (taps, buckets).
|
||||
|
||||
---
|
||||
|
||||
## 1. Versioning + version surfaces — DECIDED (→ ADR-0054, building now)
|
||||
|
||||
- **`Cargo.toml` `version` is the single source of truth.** `--version`
|
||||
reads `env!("CARGO_PKG_VERSION")` (compile-time, no deps).
|
||||
- **Two surfaces:** a `--version` / `-V` CLI flag (prints + exits, like
|
||||
`--help`), and an in-app **`version`** app command.
|
||||
- **CI discipline:** `release.yaml` gains a guard that asserts the pushed
|
||||
tag `vX.Y.Z` equals `CARGO_PKG_VERSION`, and **fails the release on
|
||||
mismatch** — so the binary's self-reported version, the release name,
|
||||
and the downloaded asset always agree.
|
||||
- **Release ritual:** bump `Cargo.toml` → commit → tag `v<that number>`
|
||||
→ push tag. (The guard enforces it.)
|
||||
- Optional enrichment (not decided): a `build.rs` baking a git short-hash
|
||||
+ build date so non-tagged dev builds read `0.2.0 (a1b2c3d)`. Good for
|
||||
bug reports; can be added later.
|
||||
|
||||
---
|
||||
|
||||
## 2. `install.sh` (curl | sh) — DONE 2026-06-17 (ADR-0055)
|
||||
|
||||
**Shipped** `scripts/install.sh` (POSIX sh, shellcheck-clean). Verified
|
||||
end-to-end against the live public `v0.1.0` release: platform mappings
|
||||
(Linux/macOS × x86_64/aarch64; Windows + unknown arch error cleanly),
|
||||
pinned (`RDBMS_VERSION`) and latest (`releases/latest`) paths, SHA-256
|
||||
verification (incl. a tamper-rejection check), install to
|
||||
`~/.local/bin`, PATH hint. **`install.ps1` (Windows) added 2026-06-17**
|
||||
(user chose both a one-liner *and* Scoop/winget; ADR-0055 Amendment 1):
|
||||
`irm | iex`, maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`,
|
||||
SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH —
|
||||
**written but untested from this env** (no PowerShell; validate on
|
||||
Windows). The website copy that references these commands is the
|
||||
**website branch's** job (separate agent), later.
|
||||
A **shellcheck CI gate** for `scripts/` is a recommended follow-up (not
|
||||
added — shellcheck isn't in the flake yet; touches ADR-ci-002).
|
||||
|
||||
Original decided shape (for reference):
|
||||
|
||||
- **Hosted from the Gitea repo URL** on `git.lazyeval.net` (simplest):
|
||||
`curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh`
|
||||
(exact raw-URL form to confirm against the Gitea version).
|
||||
- **Behaviour:** POSIX `sh`; detect `uname` OS+arch → target triple
|
||||
(Linux → the **musl static** build, our universal Linux artifact);
|
||||
query the latest release via the Gitea API
|
||||
(`/repos/oli/rdbms-playground/releases/latest`) → tag → deterministic
|
||||
asset name `rdbms-playground-<tag>-<target>`; download + **verify the
|
||||
`.sha256`**; install to `~/.local/bin` (fallback `/usr/local/bin` with
|
||||
a sudo prompt); `chmod +x`; print a PATH hint if needed.
|
||||
- **macOS:** binaries are signed (see §4 signing note); a `curl`
|
||||
download does **not** apply the Gatekeeper quarantine xattr, so the
|
||||
installed binary runs without `xattr` faff.
|
||||
- **Windows:** not `curl | sh` — provide a PowerShell `install.ps1`
|
||||
(`irm … | iex`) and/or steer to Scoop/winget (§3).
|
||||
- **Security posture:** HTTPS only; in-script checksum verification;
|
||||
document the download-inspect-run alternative (`curl|sh` is a trust
|
||||
tradeoff).
|
||||
- **Deliverables we own now:** `scripts/install.sh` (+ later
|
||||
`install.ps1`); confirm releases are **publicly downloadable**; decide
|
||||
whether to also upload `install.sh` as a release asset for a stable
|
||||
link. Website copy referencing the command is the **website branch's**
|
||||
job (separate agent), later.
|
||||
|
||||
---
|
||||
|
||||
## 3. D3 — package managers (roadmap; each layers on the release assets)
|
||||
|
||||
Common thread: a manifest pointing at our checksummed assets + a
|
||||
per-release step to bump it. Ordered cheapest → most gatekept.
|
||||
|
||||
### 3a. `cargo binstall` + crates.io — PREPARED 2026-06-17 (ADR-0056)
|
||||
|
||||
**Done (prep):** crate made publish-ready (dropped `publish = false`;
|
||||
added `homepage`/`keywords`/`categories`/`exclude`; authored `README.md`
|
||||
+ `LICENSE-MIT`); `[package.metadata.binstall]` added with per-target
|
||||
overrides (linux-gnu→musl, windows-msvc→gnu/gnullvm; macOS direct);
|
||||
`cargo publish --dry-run` clean (913 KiB compressed). Dual license kept;
|
||||
`LICENSE-MIT` + `LICENSE-APACHE` (© Lazy Evaluation Ltd) + `CONTRIBUTING.md`
|
||||
(inbound=outbound) all in place. **Gated / remaining:** the actual `cargo
|
||||
publish` (token, irreversible) at a **new tagged release** (not 0.1.0); a
|
||||
real `cargo binstall` validation.
|
||||
|
||||
- **Bootstrapping matters (user-flagged):** `binstall` is **not** a
|
||||
built-in cargo subcommand — users must install **`cargo-binstall`**
|
||||
first (its own `curl|sh`/PowerShell installer, or
|
||||
`cargo install cargo-binstall`). **Our instructions must say this.**
|
||||
- Add `[package.metadata.binstall]` to `Cargo.toml` (pkg-url template →
|
||||
our Gitea release assets; our naming already fits).
|
||||
- **DECIDED (2026-06-17): publish to crates.io** — so the frictionless
|
||||
`cargo binstall rdbms-playground` resolves the crate, and the project
|
||||
is discoverable there. (A crates.io publish is its own small task:
|
||||
metadata completeness — description/license/repository/keywords/readme
|
||||
— and `cargo publish`; the `[package.metadata.binstall]` URL template
|
||||
points binstall at our Gitea release assets.) *(Verify current
|
||||
cargo-binstall behaviour when wiring.)*
|
||||
|
||||
### 3b. Scoop (Windows)
|
||||
- A **bucket** repo under `lazyeval` on Gitea with a JSON manifest
|
||||
(`.exe` URL + hash + `autoupdate`). Release job commits the bump.
|
||||
- Users: `scoop bucket add lazyeval <gitea-url>; scoop install rdbms-playground`.
|
||||
|
||||
### 3c. Homebrew (macOS/Linux)
|
||||
- A **company-branded tap** — `lazyeval/homebrew-tap` (on Gitea) — with a
|
||||
Ruby formula (per-arch `url` + `sha256`). Release job commits the bump.
|
||||
- Users: `brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap`
|
||||
then `brew install lazyeval/tap/rdbms-playground` (the explicit-URL tap
|
||||
form, since the `user/repo` shorthand assumes GitHub).
|
||||
- *"Notability bars"* = the acceptance criteria for the default
|
||||
**homebrew-core** tap (must be sufficiently popular/maintained). Our
|
||||
own `lazyeval` tap sidesteps that entirely — no review gate.
|
||||
|
||||
### 3d. winget (Windows)
|
||||
- Manifests are YAML PR'd to `microsoft/winget-pkgs` (reviewed by MS).
|
||||
- **`wingetcreate` is Windows-only** (.NET) — no good without a Windows
|
||||
runner. **Automatic path to evaluate first: `komac`** — a
|
||||
cross-platform (Rust) winget manifest creator/submitter that runs on
|
||||
our **Linux** CI. *(Verify komac's current capabilities/auth model.)*
|
||||
- **Fallback:** a manual YAML PR per release — acceptable given releases
|
||||
are infrequent (user-confirmed).
|
||||
|
||||
### Cross-cutting (3a–3d)
|
||||
- Two extra repos (tap + bucket) under `lazyeval`, with CI push
|
||||
credentials — setup TBD (user: "we'll figure that out").
|
||||
- **`cargo-dist`/"dist"** automates installers + Homebrew + CI, but is
|
||||
**GitHub-Actions/Releases-centric**; on self-hosted Gitea it won't drop
|
||||
in cleanly (installer-script generation might be reusable). Likely
|
||||
hand-roll the manifests + a small "update on release" job instead.
|
||||
*(Verify cargo-dist's current Gitea support before fully ruling out.)*
|
||||
|
||||
---
|
||||
|
||||
## Open decisions
|
||||
|
||||
1. **crates.io:** **RESOLVED 2026-06-17 — yes, publish.** (See §3a.)
|
||||
2. **Tracking:** **RESOLVED 2026-06-17 — doc + ADR only, no Gitea
|
||||
issues.**
|
||||
3. **Release downloads public:** **CONFIRMED 2026-06-17** — the Gitea
|
||||
releases are publicly downloadable (no auth); `install.sh` relies on
|
||||
it and was verified against the live `v0.1.0`.
|
||||
|
||||
### Still open / postponed
|
||||
|
||||
- **macOS signing — CONFIRMED BUG (2026-06-16), POSTPONED by the user
|
||||
(2026-06-17)** pending the correct signing ID. Details:
|
||||
- `release-macos.yaml` does `codesign --force --sign -` (ad-hoc) and has
|
||||
**no signing scaffolding at all** (no keychain import, no secrets) —
|
||||
so a downloaded binary is *not* properly signed (user-verified).
|
||||
- **The credential the user has is the wrong type:** `Apple Development:
|
||||
Oliver Sturm (W687M898E4)` is a *development* cert (Gatekeeper won't
|
||||
trust it for distribution). Distribution needs a **`Developer ID
|
||||
Application`** cert (same format, different type). Signing under the
|
||||
company name *"Lazy Evaluation Ltd"* would need an **Organization**
|
||||
Apple Developer account; a personal account signs as "Oliver Sturm".
|
||||
- **Notarization** (required with Developer ID for non-quarantined trust
|
||||
on browser downloads): after signing, `xcrun notarytool submit`. Creds
|
||||
= an **App Store Connect API key** (Issuer ID + Key ID + `.p8`,
|
||||
recommended for CI) *or* Apple ID + app-specific password + Team ID.
|
||||
A bare CLI binary can't be *stapled* (only bundles/dmg/pkg) — Gatekeeper
|
||||
does an online check instead.
|
||||
- **Urgency caveat:** the `curl|sh` path doesn't need any of this (curl
|
||||
downloads aren't quarantined); signing matters for browser downloads
|
||||
from the releases page. Fix when the right cert + creds exist; corrects
|
||||
the ad-hoc docs once landed.
|
||||
|
||||
## Sequencing
|
||||
|
||||
1. ✅ **Version discipline** (ADR-0054) — `--version`/`-V` + `version`
|
||||
command + CI tag-match guard + tests.
|
||||
2. ✅ **`scripts/install.sh`** (ADR-0055) — built + verified against the
|
||||
live public release.
|
||||
3. Package managers, cheapest first:
|
||||
- ✅ **`cargo binstall` + crates.io** — *prepared* (ADR-0056);
|
||||
publish gated on a new tagged release + the token.
|
||||
- **← next:** Scoop (`lazyeval` bucket) → Homebrew (`lazyeval` tap) →
|
||||
winget (komac / manual). Two `lazyeval` repos (tap + bucket) + CI
|
||||
push creds to set up.
|
||||
4. **Cut a release at a new version** — bump `Cargo.toml` (0.1.0 →
|
||||
0.1.1/0.2.0; the ADR-0054 guard checks the tag), tag, push; the four
|
||||
Linux/Windows targets build immediately. (macOS leg awaits signing.)
|
||||
@@ -70,8 +70,11 @@ since ADR-0027.)
|
||||
natively on a Tart Apple-Silicon runner via the dispatched
|
||||
`release-macos.yaml`. All uploaded to the Gitea release with a
|
||||
`.sha256` each. Decisions in `docs/ci/adr/` (ADR-ci-001/002/003).
|
||||
Runtime-verified by the user: Linux x86_64 + Windows aarch64; the
|
||||
others are link-clean / valid format.)*
|
||||
Runtime-verified by the user: Linux x86_64, Windows aarch64, and both
|
||||
macOS targets (the `release-macos.yaml` dispatch — now triggerable
|
||||
since CI is on `main` — was run end-to-end and the binaries launch);
|
||||
Linux aarch64 + Windows x86_64 remain link-clean / valid-format
|
||||
only.)*
|
||||
- [x] **D2** Single static binary, no runtime dependencies.
|
||||
*(Done 2026-06-15, per platform: **Linux** is fully static (musl +
|
||||
`crt-static`); **Windows** is a standalone `.exe` (Zig statically
|
||||
@@ -888,7 +891,10 @@ since ADR-0027.)
|
||||
exists (~55 lines, covers the WHERE-expression and
|
||||
table-creation boundaries). **Missing:** a DSL
|
||||
command-surface reference and a standalone type-system
|
||||
reference under `docs/`.)*
|
||||
reference under `docs/`. **Note (2026-06-16):** the **canonical**
|
||||
user docs now live on the **website** (ADR-website-001, deployed) —
|
||||
it covers the full feature set; the in-repo `docs/` reference pieces
|
||||
named here remain the outstanding part of DOC1.)*
|
||||
|
||||
## Testing (per ADR-0008)
|
||||
|
||||
|
||||
@@ -16,4 +16,4 @@ applies here too).
|
||||
|
||||
## Index
|
||||
|
||||
- [ADR-website-001 — Public website and documentation site](20260604-adr-website-001.md) — **Accepted 2026-06-04** (formerly ADR-0044 in the main index; moved here 2026-06-10 to end recurring cross-branch number collisions). The first public website: a marketing landing page plus the **canonical** user docs. Stack **Astro 6 + Starlight + Tailwind v4** (chosen over SvelteKit + Tailwind for a docs-heavy + marketing site; interactive bits as Astro islands). Showcase demos are **asciinema** `.cast` recordings (scripted-input driver for paced, re-recordable sessions — *not* `history.log` replay), reused inline in docs. The **in-page WASM playground is deferred** (OOS: deferred) behind a stable `Demo` seam, with the portable-core vs native-edge boundary recorded for a future ADR + iteration plan. Portable **static build** (**Cloudflare** target via **Gitea Actions**, host-agnostic); **no CI yet**; **monorepo** (`website/`). Docs cover the **full supported feature set** with "planned" callouts for the unshipped minority; two wording rules bind user-facing copy — **no engine name** (continues ADR-0002) and **no "DSL"** ("simple mode" / "advanced mode"). Install docs cover **prebuilt binaries + package managers** (D1–D3 track the release tooling). Plan: [`docs/website/plans/20260604-website-implementation-plan.md`](../plans/20260604-website-implementation-plan.md)
|
||||
- [ADR-website-001 — Public website and documentation site](20260604-adr-website-001.md) — **Accepted 2026-06-04** (formerly ADR-0044 in the main index; moved here 2026-06-10 to end recurring cross-branch number collisions). The first public website: a marketing landing page plus the **canonical** user docs. Stack **Astro 6 + Starlight + Tailwind v4** (chosen over SvelteKit + Tailwind for a docs-heavy + marketing site; interactive bits as Astro islands). Showcase demos are **asciinema** `.cast` recordings (scripted-input driver for paced, re-recordable sessions — *not* `history.log` replay), reused inline in docs. The **in-page WASM playground is deferred** (OOS: deferred) behind a stable `Demo` seam, with the portable-core vs native-edge boundary recorded for a future ADR + iteration plan. Portable **static build** (**Cloudflare** target via **Gitea Actions**, host-agnostic); **monorepo** (`website/`). **§4 update (CI implemented):** the static→Cloudflare Pages deploy now runs via Gitea Actions (`.gitea/workflows/website.yaml`; the crate gate is skipped for website-only changes); both `website` and `main` are merged and the site is **deployed**. Docs cover the **full supported feature set** with "planned" callouts for the unshipped minority; two wording rules bind user-facing copy — **no engine name** (continues ADR-0002) and **no "DSL"** ("simple mode" / "advanced mode"). Install docs cover **prebuilt binaries + package managers** (D1–D3 track the release tooling). Plan: [`docs/website/plans/20260604-website-implementation-plan.md`](../plans/20260604-website-implementation-plan.md)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Download and install a prebuilt rdbms-playground binary (Windows).
|
||||
|
||||
.DESCRIPTION
|
||||
The Windows counterpart of scripts/install.sh. Detects the CPU
|
||||
architecture, downloads the matching release .exe from the Gitea
|
||||
releases, verifies its SHA-256 checksum, installs it to
|
||||
%LOCALAPPDATA%\Programs\rdbms-playground, and adds that directory to
|
||||
your user PATH.
|
||||
|
||||
Quick start:
|
||||
irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex
|
||||
|
||||
We ship gnu / gnullvm Windows builds (x86_64 / aarch64); this maps the
|
||||
host architecture to the right asset.
|
||||
|
||||
.PARAMETER Version
|
||||
Install a specific tag (e.g. v0.2.0) instead of the latest release.
|
||||
Defaults to $env:RDBMS_VERSION, else the latest release.
|
||||
|
||||
.PARAMETER InstallDir
|
||||
Install directory. Defaults to $env:RDBMS_INSTALL_DIR, else
|
||||
%LOCALAPPDATA%\Programs\rdbms-playground.
|
||||
|
||||
.NOTES
|
||||
Written but NOT tested on Windows from this environment (no PowerShell
|
||||
here) — validate on a real Windows host. The verified sibling is
|
||||
install.sh (Linux/macOS).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Version = $env:RDBMS_VERSION,
|
||||
[string]$InstallDir = $(if ($env:RDBMS_INSTALL_DIR) { $env:RDBMS_INSTALL_DIR } else { "$env:LOCALAPPDATA\Programs\rdbms-playground" })
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$Repo = 'https://git.lazyeval.net/oli/rdbms-playground'
|
||||
$Api = 'https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground'
|
||||
$Bin = 'rdbms-playground'
|
||||
|
||||
# Map the host CPU to the target triple we publish for Windows.
|
||||
$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
|
||||
switch ($osArch) {
|
||||
'X64' { $target = 'x86_64-pc-windows-gnu' }
|
||||
'Arm64' { $target = 'aarch64-pc-windows-gnullvm' }
|
||||
default { throw "install: unsupported CPU architecture: $osArch" }
|
||||
}
|
||||
|
||||
# Resolve the release tag (explicit -Version, else the latest release).
|
||||
if (-not $Version) {
|
||||
$Version = (Invoke-RestMethod -Uri "$Api/releases/latest").tag_name
|
||||
if (-not $Version) { throw 'install: could not determine the latest release tag' }
|
||||
}
|
||||
|
||||
$asset = "$Bin-$Version-$target.exe"
|
||||
$url = "$Repo/releases/download/$Version/$asset"
|
||||
|
||||
$tmp = Join-Path $env:TEMP ([System.Guid]::NewGuid().ToString())
|
||||
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
|
||||
try {
|
||||
$exe = Join-Path $tmp "$Bin.exe"
|
||||
$shaFile = "$exe.sha256"
|
||||
|
||||
Write-Host "downloading $asset ..."
|
||||
Invoke-WebRequest -Uri $url -OutFile $exe
|
||||
Invoke-WebRequest -Uri "$url.sha256" -OutFile $shaFile
|
||||
|
||||
# The sidecar is "<hash> <name>"; compare just the hash.
|
||||
$expected = ((Get-Content -Raw $shaFile) -split '\s+')[0].ToLower()
|
||||
$actual = (Get-FileHash -Algorithm SHA256 -Path $exe).Hash.ToLower()
|
||||
if ($expected -ne $actual) {
|
||||
throw "install: checksum mismatch (expected $expected, got $actual) — refusing to install"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
$dest = Join-Path $InstallDir "$Bin.exe"
|
||||
Move-Item -Path $exe -Destination $dest -Force
|
||||
Write-Host "installed $Bin $Version -> $dest"
|
||||
|
||||
# Add the install dir to the user PATH (persistent) if it's not there.
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
if (-not $userPath) { $userPath = '' }
|
||||
if (($userPath -split ';') -notcontains $InstallDir) {
|
||||
$newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir }
|
||||
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
|
||||
Write-Host "added $InstallDir to your user PATH — restart your shell to pick it up"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/bin/sh
|
||||
# install.sh — download and install a prebuilt rdbms-playground binary.
|
||||
#
|
||||
# Quick start (Linux / macOS):
|
||||
# curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh
|
||||
#
|
||||
# What it does: detects your OS + CPU, downloads the matching release binary
|
||||
# from the Gitea releases (Linux uses the fully-static musl build), verifies
|
||||
# its SHA-256 checksum, and installs it to ~/.local/bin.
|
||||
#
|
||||
# Environment overrides:
|
||||
# RDBMS_VERSION install a specific tag (e.g. v0.2.0) instead of the latest
|
||||
# RDBMS_INSTALL_DIR install directory (default: $HOME/.local/bin)
|
||||
# RDBMS_OS force the OS (testing): Linux | Darwin
|
||||
# RDBMS_ARCH force the CPU (testing): x86_64 | aarch64
|
||||
#
|
||||
# Flags:
|
||||
# --print-target print the resolved target triple and exit (no download)
|
||||
# -h, --help print this help and exit
|
||||
#
|
||||
# Notes:
|
||||
# * Windows is not installable via this script (the binary is a .exe) —
|
||||
# use Scoop/winget (planned) or download the .exe from the releases page.
|
||||
# * macOS: a curl download is not quarantined by Gatekeeper, so the binary
|
||||
# runs without extra steps. (Developer-ID signing + notarization is a
|
||||
# separate, planned improvement for browser downloads.)
|
||||
#
|
||||
# POSIX sh — no bashisms, so it runs under the `sh` of `curl | sh`.
|
||||
|
||||
set -eu
|
||||
|
||||
REPO_BASE="https://git.lazyeval.net/oli/rdbms-playground"
|
||||
API_BASE="https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground"
|
||||
BIN_NAME="rdbms-playground"
|
||||
PRINT_TARGET=0
|
||||
|
||||
err() {
|
||||
printf 'install: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
info() { printf '%s\n' "$1" >&2; }
|
||||
|
||||
usage() {
|
||||
# Lines 2..(first blank) of this file are the human-readable header.
|
||||
sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
# Resolve the Rust target triple for the current (or forced) platform.
|
||||
# Linux -> <arch>-unknown-linux-musl (the fully-static build)
|
||||
# macOS -> <arch>-apple-darwin
|
||||
detect_target() {
|
||||
os="${RDBMS_OS:-$(uname -s)}"
|
||||
arch="${RDBMS_ARCH:-$(uname -m)}"
|
||||
case "$os" in
|
||||
Linux | linux) os_part="unknown-linux-musl" ;;
|
||||
Darwin | darwin | macos | macOS) os_part="apple-darwin" ;;
|
||||
MINGW* | MSYS* | CYGWIN* | *Windows* | *windows*)
|
||||
err "Windows is not supported by this installer — use Scoop/winget (planned) or download the .exe from $REPO_BASE/releases" ;;
|
||||
*) err "unsupported operating system: $os" ;;
|
||||
esac
|
||||
case "$arch" in
|
||||
x86_64 | amd64) arch_part="x86_64" ;;
|
||||
aarch64 | arm64) arch_part="aarch64" ;;
|
||||
*) err "unsupported CPU architecture: $arch" ;;
|
||||
esac
|
||||
printf '%s-%s' "$arch_part" "$os_part"
|
||||
}
|
||||
|
||||
# Download $1 to file $2 (curl or wget).
|
||||
download() {
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$1" -o "$2"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO "$2" "$1"
|
||||
else
|
||||
err "need either curl or wget on PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fetch $1 to stdout (curl or wget).
|
||||
fetch() {
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$1"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO- "$1"
|
||||
else
|
||||
err "need either curl or wget on PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
# The release tag to install: $RDBMS_VERSION if set, else the latest release.
|
||||
resolve_version() {
|
||||
if [ -n "${RDBMS_VERSION:-}" ]; then
|
||||
printf '%s' "$RDBMS_VERSION"
|
||||
return
|
||||
fi
|
||||
json=$(fetch "$API_BASE/releases/latest") ||
|
||||
err "could not query the latest release from $API_BASE"
|
||||
# Portable JSON scrape (no jq): the latest-release object carries exactly
|
||||
# one "tag_name": "<tag>" field.
|
||||
tag=$(printf '%s' "$json" |
|
||||
grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' |
|
||||
head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
||||
[ -n "$tag" ] || err "could not parse the latest release tag"
|
||||
printf '%s' "$tag"
|
||||
}
|
||||
|
||||
# Verify file $1 against sha256 sidecar $2 (format: "<hash> <name>").
|
||||
verify_checksum() {
|
||||
expected=$(awk '{print $1; exit}' "$2")
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual=$(sha256sum "$1" | awk '{print $1}')
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
actual=$(shasum -a 256 "$1" | awk '{print $1}')
|
||||
else
|
||||
info "warning: no sha256 tool found — skipping checksum verification"
|
||||
return 0
|
||||
fi
|
||||
[ "$expected" = "$actual" ] ||
|
||||
err "checksum mismatch (expected $expected, got $actual) — refusing to install"
|
||||
}
|
||||
|
||||
main() {
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--print-target) PRINT_TARGET=1 ;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*) err "unknown argument: $1 (try --help)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
target=$(detect_target)
|
||||
if [ "$PRINT_TARGET" = "1" ]; then
|
||||
printf '%s\n' "$target"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
version=$(resolve_version)
|
||||
asset="$BIN_NAME-$version-$target"
|
||||
url="$REPO_BASE/releases/download/$version/$asset"
|
||||
dir="${RDBMS_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
tmp=$(mktemp -d 2>/dev/null) || err "could not create a temporary directory"
|
||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
|
||||
info "downloading $asset ..."
|
||||
download "$url" "$tmp/$BIN_NAME" || err "download failed: $url"
|
||||
download "$url.sha256" "$tmp/$BIN_NAME.sha256" || err "checksum download failed: $url.sha256"
|
||||
verify_checksum "$tmp/$BIN_NAME" "$tmp/$BIN_NAME.sha256"
|
||||
|
||||
mkdir -p "$dir" || err "could not create install directory: $dir"
|
||||
chmod +x "$tmp/$BIN_NAME"
|
||||
mv "$tmp/$BIN_NAME" "$dir/$BIN_NAME" || err "could not install to $dir"
|
||||
info "installed $BIN_NAME $version -> $dir/$BIN_NAME"
|
||||
|
||||
case ":${PATH:-}:" in
|
||||
*":$dir:"*) ;;
|
||||
*) info "note: $dir is not on your PATH. Add it, e.g.: export PATH=\"$dir:\$PATH\"" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+237
-186
@@ -509,7 +509,10 @@ pub enum LoadPickerSubMode {
|
||||
/// Switched to via `b`. Same input/cursor surface as
|
||||
/// `PathEntryModal`; kept inline so the picker can flip
|
||||
/// back to List with `Esc`.
|
||||
PathEntry { input: String, cursor: usize },
|
||||
PathEntry {
|
||||
input: String,
|
||||
cursor: usize,
|
||||
},
|
||||
}
|
||||
|
||||
const PAGE_SCROLL_LINES: usize = 5;
|
||||
@@ -697,9 +700,7 @@ impl App {
|
||||
// `trimmed[1..].trim()`.
|
||||
let leading_ws = self.input.len() - self.input.trim_start().len();
|
||||
let mut offset = leading_ws + 1; // past the `:`
|
||||
while offset < self.input.len()
|
||||
&& self.input.as_bytes()[offset].is_ascii_whitespace()
|
||||
{
|
||||
while offset < self.input.len() && self.input.as_bytes()[offset].is_ascii_whitespace() {
|
||||
offset += 1;
|
||||
}
|
||||
let view = &self.input[offset..];
|
||||
@@ -727,8 +728,7 @@ impl App {
|
||||
pub fn input_validity_verdict(&self) -> Option<crate::dsl::walker::Severity> {
|
||||
let mode = match self.effective_mode() {
|
||||
EffectiveMode::Simple => Mode::Simple,
|
||||
EffectiveMode::AdvancedPersistent
|
||||
| EffectiveMode::AdvancedOneShot => Mode::Advanced,
|
||||
EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => Mode::Advanced,
|
||||
};
|
||||
// Strip the `:` one-shot prefix so the walker verdicts the SQL
|
||||
// itself, not the escape marker (which it can't parse).
|
||||
@@ -1037,10 +1037,7 @@ impl App {
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::ExportSucceeded { path } => {
|
||||
self.note_system(crate::t!(
|
||||
"project.export_ok",
|
||||
path = path.display()
|
||||
));
|
||||
self.note_system(crate::t!("project.export_ok", path = path.display()));
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::ExportFailed { error } => {
|
||||
@@ -1056,11 +1053,7 @@ impl App {
|
||||
// `[ok] replay — N command(s)` summary is payload-bearing
|
||||
// (the count) and stays.
|
||||
self.mark_oldest_pending_echo(EchoStatus::Ok);
|
||||
self.note_system(crate::t!(
|
||||
"replay.completed",
|
||||
path = path,
|
||||
count = count
|
||||
));
|
||||
self.note_system(crate::t!("replay.completed", path = path, count = count));
|
||||
// ADR-0034: surface `[skip]` warnings for app-lifecycle
|
||||
// commands whose omission can leave the replayed state
|
||||
// incomplete (`import`, nested `replay`).
|
||||
@@ -1084,11 +1077,7 @@ impl App {
|
||||
// it, mirroring how the interactive `running: …`
|
||||
// path renders source-line context above an error.
|
||||
if line_number == 0 {
|
||||
self.note_error(crate::t!(
|
||||
"replay.failed_open",
|
||||
path = path,
|
||||
error = error
|
||||
));
|
||||
self.note_error(crate::t!("replay.failed_open", path = path, error = error));
|
||||
} else {
|
||||
self.note_error(crate::t!(
|
||||
"replay.failed_at_line",
|
||||
@@ -1097,10 +1086,7 @@ impl App {
|
||||
error = error
|
||||
));
|
||||
if !command.is_empty() {
|
||||
self.note_error(crate::t!(
|
||||
"replay.command_echo",
|
||||
command = command
|
||||
));
|
||||
self.note_error(crate::t!("replay.command_echo", command = command));
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
@@ -1237,8 +1223,7 @@ impl App {
|
||||
// stays F1-only.
|
||||
let hint_key = key.code == KeyCode::F(1)
|
||||
|| (self.demo_mode
|
||||
&& (key.code, key.modifiers)
|
||||
== (KeyCode::Char('g'), KeyModifiers::CONTROL));
|
||||
&& (key.code, key.modifiers) == (KeyCode::Char('g'), KeyModifiers::CONTROL));
|
||||
if hint_key {
|
||||
if self.input.trim().is_empty() {
|
||||
self.note_hint_for_recent_error();
|
||||
@@ -1369,8 +1354,8 @@ impl App {
|
||||
/// against crossterm 0.29). Only active in demo mode (the caller
|
||||
/// gates on `self.demo_mode`).
|
||||
fn handle_demo_caption_key(&mut self, key: KeyEvent) -> Option<Vec<Action>> {
|
||||
let is_toggle = key.code == KeyCode::Char('5')
|
||||
&& key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let is_toggle =
|
||||
key.code == KeyCode::Char('5') && key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
|
||||
if self.demo_caption_capturing {
|
||||
if is_toggle {
|
||||
@@ -1379,8 +1364,7 @@ impl App {
|
||||
self.demo_caption_capturing = false;
|
||||
let text = std::mem::take(&mut self.demo_caption_buffer);
|
||||
let trimmed = text.trim();
|
||||
self.demo_caption =
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string());
|
||||
self.demo_caption = (!trimmed.is_empty()).then(|| trimmed.to_string());
|
||||
} else {
|
||||
match key.code {
|
||||
// Plain characters accumulate invisibly; the prompt
|
||||
@@ -1553,7 +1537,10 @@ impl App {
|
||||
&self.schema_cache,
|
||||
self.effective_mode().as_mode(),
|
||||
)?;
|
||||
comp.replaced_range = (comp.replaced_range.0 + offset, comp.replaced_range.1 + offset);
|
||||
comp.replaced_range = (
|
||||
comp.replaced_range.0 + offset,
|
||||
comp.replaced_range.1 + offset,
|
||||
);
|
||||
Some(comp)
|
||||
}
|
||||
|
||||
@@ -1581,8 +1568,7 @@ impl App {
|
||||
idx: usize,
|
||||
) -> crate::completion::LastCompletion {
|
||||
let inserted = comp.candidates[idx].text.clone();
|
||||
let original_text =
|
||||
self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
|
||||
let original_text = self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
|
||||
self.input
|
||||
.replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted);
|
||||
let new_end = comp.replaced_range.0 + inserted.len();
|
||||
@@ -1777,7 +1763,10 @@ impl App {
|
||||
// teaching echo (ADR-0038) on an advanced effective mode.
|
||||
let (submission_mode, effective_input) =
|
||||
if self.mode == Mode::Simple && trimmed.starts_with(':') {
|
||||
(EffectiveMode::AdvancedOneShot, trimmed[1..].trim().to_string())
|
||||
(
|
||||
EffectiveMode::AdvancedOneShot,
|
||||
trimmed[1..].trim().to_string(),
|
||||
)
|
||||
} else if self.mode == Mode::Advanced {
|
||||
(EffectiveMode::AdvancedPersistent, trimmed.to_string())
|
||||
} else {
|
||||
@@ -1845,11 +1834,7 @@ impl App {
|
||||
/// simple and advanced modes; the parse-first refactor
|
||||
/// (round-5) routes app commands here before the
|
||||
/// mode-specific DSL/SQL paths.
|
||||
fn dispatch_app_command(
|
||||
&mut self,
|
||||
cmd: crate::dsl::AppCommand,
|
||||
source: &str,
|
||||
) -> Vec<Action> {
|
||||
fn dispatch_app_command(&mut self, cmd: crate::dsl::AppCommand, source: &str) -> Vec<Action> {
|
||||
use crate::dsl::{AppCommand, MessagesValue, ModeValue};
|
||||
debug!(command = ?cmd, "dispatch app command");
|
||||
match cmd {
|
||||
@@ -1868,6 +1853,12 @@ impl App {
|
||||
self.note_hint_for_recent_error();
|
||||
Vec::new()
|
||||
}
|
||||
// ADR-0054: the in-app twin of `--version`. Reports the same
|
||||
// single source of truth (`CARGO_PKG_VERSION`, via cli::version_text).
|
||||
AppCommand::Version => {
|
||||
self.note_system(crate::cli::version_text());
|
||||
Vec::new()
|
||||
}
|
||||
AppCommand::Rebuild => vec![Action::PrepareRebuild],
|
||||
AppCommand::Save => self.handle_save_command(false),
|
||||
AppCommand::SaveAs => self.handle_save_command(true),
|
||||
@@ -2003,11 +1994,8 @@ impl App {
|
||||
// mode so the walker gates SQL-only forms — simple-mode
|
||||
// `select` returns the "this is SQL" hint as a normal
|
||||
// parse error and is rendered through the Err arm below.
|
||||
match crate::dsl::parser::parse_command_with_schema_in_mode(
|
||||
input,
|
||||
&self.schema_cache,
|
||||
mode,
|
||||
) {
|
||||
match crate::dsl::parser::parse_command_with_schema_in_mode(input, &self.schema_cache, mode)
|
||||
{
|
||||
Ok(Command::Replay { path }) => {
|
||||
// `replay` is parsed as a DSL command for the
|
||||
// sake of grammar uniformity, but its execution
|
||||
@@ -2121,15 +2109,9 @@ impl App {
|
||||
.get(..*position)
|
||||
.map_or(*position, |s| s.chars().count());
|
||||
let pad = prefix.chars().count() + chars_before;
|
||||
self.note_error(crate::t!(
|
||||
"parse.caret",
|
||||
padding = " ".repeat(pad)
|
||||
));
|
||||
self.note_error(crate::t!("parse.caret", padding = " ".repeat(pad)));
|
||||
}
|
||||
self.note_error(crate::t!(
|
||||
"parse.error",
|
||||
detail = parse_error_message(&err)
|
||||
));
|
||||
self.note_error(crate::t!("parse.error", detail = parse_error_message(&err)));
|
||||
// ADR-0033 Amendment 3: combine the DSL error with a
|
||||
// pointer to advanced mode when the same line would
|
||||
// run as SQL there. Only in simple mode (a one-shot
|
||||
@@ -2222,7 +2204,11 @@ impl App {
|
||||
| Command::AddRelationship { .. }
|
||||
| Command::DropRelationship { .. }
|
||||
) {
|
||||
debug!(verb = command.verb(), width = self.last_output_width, "render: relationship diagrams (ADR-0044)");
|
||||
debug!(
|
||||
verb = command.verb(),
|
||||
width = self.last_output_width,
|
||||
"render: relationship diagrams (ADR-0044)"
|
||||
);
|
||||
for line in crate::output_render::render_structure_with_diagrams(
|
||||
desc,
|
||||
self.last_output_width,
|
||||
@@ -2246,11 +2232,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_dsl_explain_success(
|
||||
&mut self,
|
||||
command: &Command,
|
||||
plan: &crate::db::QueryPlan,
|
||||
) {
|
||||
fn handle_dsl_explain_success(&mut self, command: &Command, plan: &crate::db::QueryPlan) {
|
||||
self.note_ok_summary(command);
|
||||
// ADR-0028 §3: the display SQL, then the plan tree.
|
||||
// `render_explain_plan` returns ready-built `OutputLine`s
|
||||
@@ -2344,11 +2326,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_dsl_add_column_success(
|
||||
&mut self,
|
||||
command: &Command,
|
||||
result: AddColumnResult,
|
||||
) {
|
||||
fn handle_dsl_add_column_success(&mut self, command: &Command, result: AddColumnResult) {
|
||||
self.note_ok_summary(command);
|
||||
// ADR-0018 §9 / ADR-0038 §6 category 3: emit auto-fill note(s)
|
||||
// before the structure render so the pedagogical "the tool did
|
||||
@@ -2363,19 +2341,12 @@ impl App {
|
||||
self.current_table = Some(result.description);
|
||||
}
|
||||
|
||||
fn handle_dsl_drop_column_success(
|
||||
&mut self,
|
||||
command: &Command,
|
||||
result: DropColumnResult,
|
||||
) {
|
||||
fn handle_dsl_drop_column_success(&mut self, command: &Command, result: DropColumnResult) {
|
||||
self.note_ok_summary(command);
|
||||
// ADR-0025: when `--cascade` removed covering indexes,
|
||||
// name each one so the learner sees the side effect.
|
||||
for index in &result.dropped_indexes {
|
||||
self.note_system(crate::t!(
|
||||
"ok.index_dropped_with_column",
|
||||
index = index,
|
||||
));
|
||||
self.note_system(crate::t!("ok.index_dropped_with_column", index = index,));
|
||||
}
|
||||
for line in crate::output_render::render_structure(&result.description) {
|
||||
self.note_system(line);
|
||||
@@ -2409,10 +2380,7 @@ impl App {
|
||||
lossy = note.lossy
|
||||
)
|
||||
} else {
|
||||
crate::t!(
|
||||
"client_side.transformed",
|
||||
count = note.transformed
|
||||
)
|
||||
crate::t!("client_side.transformed", count = note.transformed)
|
||||
};
|
||||
self.push_category_three_prose(line);
|
||||
}
|
||||
@@ -2577,9 +2545,7 @@ impl App {
|
||||
(Operation::RenameTable, Some(table.as_str()), None)
|
||||
}
|
||||
},
|
||||
C::SqlCreateTable { name, .. } => {
|
||||
(Operation::CreateTable, Some(name.as_str()), None)
|
||||
}
|
||||
C::SqlCreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None),
|
||||
C::DropTable { name } => (Operation::DropTable, Some(name.as_str()), None),
|
||||
C::SqlDropTable { name, .. } => (Operation::DropTable, Some(name.as_str()), None),
|
||||
C::AddColumn { table, column, .. } => (
|
||||
@@ -2629,9 +2595,7 @@ impl App {
|
||||
// SQL `CREATE [UNIQUE] INDEX` shares the add-index operation
|
||||
// (it reuses `do_add_index`); route engine/validation errors
|
||||
// through it with the parsed table.
|
||||
C::SqlCreateIndex { table, .. } => {
|
||||
(Operation::AddIndex, Some(table.as_str()), None)
|
||||
}
|
||||
C::SqlCreateIndex { table, .. } => (Operation::AddIndex, Some(table.as_str()), None),
|
||||
C::AddConstraint { table, column, .. } => (
|
||||
Operation::AddConstraint,
|
||||
Some(table.as_str()),
|
||||
@@ -2694,19 +2658,13 @@ impl App {
|
||||
// `dispatch_input` routes them through
|
||||
// `dispatch_app_command` before the DSL execution
|
||||
// pipeline that this context builder feeds.
|
||||
C::App(_) => unreachable!(
|
||||
"App commands are dispatched before reaching dsl execution"
|
||||
),
|
||||
C::App(_) => unreachable!("App commands are dispatched before reaching dsl execution"),
|
||||
};
|
||||
|
||||
TranslateContext {
|
||||
operation: Some(operation),
|
||||
table: facts
|
||||
.table
|
||||
.or_else(|| fallback_table.map(str::to_string)),
|
||||
column: facts
|
||||
.column
|
||||
.or_else(|| fallback_column.map(str::to_string)),
|
||||
table: facts.table.or_else(|| fallback_table.map(str::to_string)),
|
||||
column: facts.column.or_else(|| fallback_column.map(str::to_string)),
|
||||
child_table: facts.child_table,
|
||||
parent_table: facts.parent_table,
|
||||
parent_column: facts.parent_column,
|
||||
@@ -2812,11 +2770,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_path_entry_key(
|
||||
&mut self,
|
||||
key: KeyEvent,
|
||||
mut state: PathEntryModal,
|
||||
) -> Vec<Action> {
|
||||
fn handle_path_entry_key(&mut self, key: KeyEvent, mut state: PathEntryModal) -> Vec<Action> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.modal = None;
|
||||
@@ -2898,11 +2852,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_load_picker_key(
|
||||
&mut self,
|
||||
key: KeyEvent,
|
||||
mut state: LoadPickerModal,
|
||||
) -> Vec<Action> {
|
||||
fn handle_load_picker_key(&mut self, key: KeyEvent, mut state: LoadPickerModal) -> Vec<Action> {
|
||||
match &mut state.sub_mode {
|
||||
LoadPickerSubMode::List => match key.code {
|
||||
KeyCode::Esc => {
|
||||
@@ -3192,7 +3142,10 @@ impl App {
|
||||
.map(|c| c.text.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
self.push_category_three_prose(crate::t!("hint.ambient_expected", expected = names));
|
||||
self.push_category_three_prose(crate::t!(
|
||||
"hint.ambient_expected",
|
||||
expected = names
|
||||
));
|
||||
}
|
||||
None => self.note_getting_started(),
|
||||
}
|
||||
@@ -3407,10 +3360,7 @@ fn render_usage_block(input: &str, mode: Mode) -> String {
|
||||
.into_iter()
|
||||
.map(|w| format!("`{w}`"))
|
||||
.collect();
|
||||
crate::t!(
|
||||
"parse.available_commands",
|
||||
commands = names.join(", ")
|
||||
)
|
||||
crate::t!("parse.available_commands", commands = names.join(", "))
|
||||
}
|
||||
|
||||
fn render_cascade_effect(effect: &CascadeEffect) -> String {
|
||||
@@ -3418,9 +3368,7 @@ fn render_cascade_effect(effect: &CascadeEffect) -> String {
|
||||
let action_key = match effect.action {
|
||||
ReferentialAction::Cascade => "db.cascade.action_deleted",
|
||||
ReferentialAction::SetNull => "db.cascade.action_set_null",
|
||||
ReferentialAction::Restrict | ReferentialAction::NoAction => {
|
||||
"db.cascade.action_blocked"
|
||||
}
|
||||
ReferentialAction::Restrict | ReferentialAction::NoAction => "db.cascade.action_blocked",
|
||||
};
|
||||
crate::t!(
|
||||
"db.cascade.summary",
|
||||
@@ -3458,7 +3406,10 @@ mod tests {
|
||||
fn demo_badge_label_maps_the_invisible_keys() {
|
||||
let none = KeyModifiers::NONE;
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Tab, none)), Some("[TAB]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)), Some("[SHIFT-TAB]"));
|
||||
assert_eq!(
|
||||
demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)),
|
||||
Some("[SHIFT-TAB]")
|
||||
);
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Enter, none)), Some("[ENTER]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Esc, none)), Some("[ESC]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Up, none)), Some("[UP]"));
|
||||
@@ -3468,8 +3419,14 @@ mod tests {
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Home, none)), Some("[HOME]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::End, none)), Some("[END]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::PageUp, none)), Some("[PGUP]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::PageDown, none)), Some("[PGDN]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Backspace, none)), Some("[BKSP]"));
|
||||
assert_eq!(
|
||||
demo_badge_label(&ke(KeyCode::PageDown, none)),
|
||||
Some("[PGDN]")
|
||||
);
|
||||
assert_eq!(
|
||||
demo_badge_label(&ke(KeyCode::Backspace, none)),
|
||||
Some("[BKSP]")
|
||||
);
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Delete, none)), Some("[DEL]"));
|
||||
assert_eq!(
|
||||
demo_badge_label(&ke(KeyCode::Char('o'), KeyModifiers::CONTROL)),
|
||||
@@ -3487,12 +3444,24 @@ mod tests {
|
||||
#[test]
|
||||
fn demo_badge_label_none_for_glyphs_and_excluded_chords() {
|
||||
// Plain characters render their own glyph — no badge.
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)), None);
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)), None);
|
||||
assert_eq!(
|
||||
demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)),
|
||||
None
|
||||
);
|
||||
// Quit and the (Phase C) caption toggle are deliberately excluded.
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)), None);
|
||||
assert_eq!(
|
||||
demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)),
|
||||
None
|
||||
);
|
||||
// Ctrl+] decodes to Char('5')+CONTROL — must not badge.
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)), None);
|
||||
assert_eq!(
|
||||
demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3600,7 +3569,10 @@ mod tests {
|
||||
assert!(app.demo_caption_capturing, "still capturing");
|
||||
assert_eq!(app.demo_caption_buffer, "note");
|
||||
assert_eq!(app.input, "");
|
||||
assert_eq!(app.demo_badge, None, "inert keys raise no badge while capturing");
|
||||
assert_eq!(
|
||||
app.demo_badge, None,
|
||||
"inert keys raise no badge while capturing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4205,7 +4177,9 @@ mod tests {
|
||||
type_str(&mut app, "copy sideways");
|
||||
let actions = submit(&mut app);
|
||||
assert!(
|
||||
!actions.iter().any(|a| matches!(a, Action::CopyToClipboard(_))),
|
||||
!actions
|
||||
.iter()
|
||||
.any(|a| matches!(a, Action::CopyToClipboard(_))),
|
||||
"an unknown target does not copy",
|
||||
);
|
||||
let rendered = app
|
||||
@@ -4393,7 +4367,10 @@ mod tests {
|
||||
);
|
||||
// … names the table's columns so the user can see what's needed …
|
||||
assert!(
|
||||
out.contains("Name") && out.contains("Age") && out.contains("id") && out.contains("SerNo"),
|
||||
out.contains("Name")
|
||||
&& out.contains("Age")
|
||||
&& out.contains("id")
|
||||
&& out.contains("SerNo"),
|
||||
"missing the column-name list in: {out}",
|
||||
);
|
||||
// … and shows the column-list override targeting the non-auto columns.
|
||||
@@ -4417,9 +4394,15 @@ mod tests {
|
||||
let _ = submit(&mut app);
|
||||
let out = error_lines(&app);
|
||||
// The teaching line names the user-supplied columns …
|
||||
assert!(out.contains("Name") && out.contains("Age"), "missing non-auto column names in: {out}");
|
||||
assert!(
|
||||
out.contains("Name") && out.contains("Age"),
|
||||
"missing non-auto column names in: {out}"
|
||||
);
|
||||
// … the auto-generated columns …
|
||||
assert!(out.contains("id") && out.contains("SerNo"), "missing auto column names in: {out}");
|
||||
assert!(
|
||||
out.contains("id") && out.contains("SerNo"),
|
||||
"missing auto column names in: {out}"
|
||||
);
|
||||
// … signals the contract …
|
||||
assert!(
|
||||
out.contains("auto-generated"),
|
||||
@@ -4514,10 +4497,7 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
install_customers_schema_two_serials(&mut app);
|
||||
app.mode = Mode::Advanced;
|
||||
type_str(
|
||||
&mut app,
|
||||
"insert into Customers values (13, 'Oli', 42, 13)",
|
||||
);
|
||||
type_str(&mut app, "insert into Customers values (13, 'Oli', 42, 13)");
|
||||
let actions = submit(&mut app);
|
||||
assert!(
|
||||
actions
|
||||
@@ -4546,7 +4526,9 @@ mod tests {
|
||||
type_str(&mut app, "insert into Customers values ('Oli', 52, 3)");
|
||||
let actions = submit(&mut app);
|
||||
assert!(
|
||||
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
|
||||
!actions
|
||||
.iter()
|
||||
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
|
||||
"simple-mode Form B count mismatch must NOT dispatch; got: {actions:?}",
|
||||
);
|
||||
}
|
||||
@@ -4559,7 +4541,9 @@ mod tests {
|
||||
type_str(&mut app, "insert into Customers values ('Oli')");
|
||||
let actions = submit(&mut app);
|
||||
assert!(
|
||||
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
|
||||
!actions
|
||||
.iter()
|
||||
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
|
||||
"simple-mode Form B under-supply must NOT dispatch; got: {actions:?}",
|
||||
);
|
||||
}
|
||||
@@ -4574,7 +4558,9 @@ mod tests {
|
||||
type_str(&mut app, "insert into Customers (Name, Age) values ('Oli')");
|
||||
let actions = submit(&mut app);
|
||||
assert!(
|
||||
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
|
||||
!actions
|
||||
.iter()
|
||||
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
|
||||
"simple-mode Form A count mismatch must NOT dispatch; got: {actions:?}",
|
||||
);
|
||||
}
|
||||
@@ -4610,9 +4596,7 @@ mod tests {
|
||||
for c in &tc {
|
||||
app.schema_cache.columns.push(c.name.clone());
|
||||
}
|
||||
app.schema_cache
|
||||
.table_columns
|
||||
.insert("T".to_string(), tc);
|
||||
app.schema_cache.table_columns.insert("T".to_string(), tc);
|
||||
type_str(&mut app, "insert into T values (1, 2), (3, 4)");
|
||||
let actions = submit(&mut app);
|
||||
assert!(
|
||||
@@ -4643,11 +4627,11 @@ mod tests {
|
||||
// advanced-mode hint at all, so we look for any line carrying
|
||||
// the "mode advanced" actionable fragment that the pointer
|
||||
// always emits.
|
||||
let has_pointer = app
|
||||
.output
|
||||
.iter()
|
||||
.any(|l| l.text.contains("mode advanced"));
|
||||
assert!(!has_pointer, "unknown command must not point at advanced mode");
|
||||
let has_pointer = app.output.iter().any(|l| l.text.contains("mode advanced"));
|
||||
assert!(
|
||||
!has_pointer,
|
||||
"unknown command must not point at advanced mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4696,7 +4680,11 @@ mod tests {
|
||||
app.mode = mode;
|
||||
type_str(&mut app, input);
|
||||
match submit(&mut app).as_slice() {
|
||||
[Action::ExecuteDsl { submission_mode, .. }] => *submission_mode,
|
||||
[
|
||||
Action::ExecuteDsl {
|
||||
submission_mode, ..
|
||||
},
|
||||
] => *submission_mode,
|
||||
other => panic!("expected one ExecuteDsl; got {other:?}"),
|
||||
}
|
||||
};
|
||||
@@ -4727,7 +4715,9 @@ mod tests {
|
||||
app.update(AppEvent::DslSucceeded {
|
||||
command: cmd.clone(),
|
||||
description: None,
|
||||
echo: Some(vec!["CREATE TABLE Other (id serial PRIMARY KEY)".to_string()]),
|
||||
echo: Some(vec![
|
||||
"CREATE TABLE Other (id serial PRIMARY KEY)".to_string(),
|
||||
]),
|
||||
});
|
||||
let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
|
||||
// ADR-0040: no `[ok]` summary; with no preceding `running:` echo
|
||||
@@ -4786,7 +4776,10 @@ mod tests {
|
||||
.position(|t| t.contains("Executing SQL:"))
|
||||
.expect("an echo line");
|
||||
assert_eq!(echo_idx, 0, "teaching echo leads the output: {texts:?}");
|
||||
assert!(texts[echo_idx].contains(expected), "echo carries the SQL: {texts:?}");
|
||||
assert!(
|
||||
texts[echo_idx].contains(expected),
|
||||
"echo carries the SQL: {texts:?}"
|
||||
);
|
||||
// ADR-0038 §4 polish: every success arm now wires the echo as
|
||||
// `OutputKind::TeachingEcho` so `ui::render_output_line` fires
|
||||
// the dim-prefix + advanced-lex custom branch. Pinning this
|
||||
@@ -4905,7 +4898,9 @@ mod tests {
|
||||
description: sample_description("T"),
|
||||
client_side: None,
|
||||
},
|
||||
echo: Some(vec!["ALTER TABLE T ALTER COLUMN c SET DATA TYPE text".to_string()]),
|
||||
echo: Some(vec![
|
||||
"ALTER TABLE T ALTER COLUMN c SET DATA TYPE text".to_string(),
|
||||
]),
|
||||
dont_convert_caveat: false,
|
||||
});
|
||||
assert_echo_beneath_ok(&app, "ALTER TABLE T ALTER COLUMN c SET DATA TYPE text");
|
||||
@@ -5120,9 +5115,7 @@ mod tests {
|
||||
dont_convert_caveat: false,
|
||||
});
|
||||
assert!(
|
||||
!app.output
|
||||
.iter()
|
||||
.any(|l| l.text.contains("--dont-convert")),
|
||||
!app.output.iter().any(|l| l.text.contains("--dont-convert")),
|
||||
"no caveat in simple mode (no echo to refer to)",
|
||||
);
|
||||
}
|
||||
@@ -5192,7 +5185,10 @@ mod tests {
|
||||
);
|
||||
// Pin the `Executing SQL:` prefix repeats once per statement
|
||||
// (the plain-rendering shape until the styled-runs polish lands).
|
||||
let exec_count = texts.iter().filter(|t| t.contains("Executing SQL:")).count();
|
||||
let exec_count = texts
|
||||
.iter()
|
||||
.filter(|t| t.contains("Executing SQL:"))
|
||||
.count();
|
||||
assert_eq!(exec_count, 3, "one Executing SQL: per statement: {texts:?}");
|
||||
}
|
||||
|
||||
@@ -5217,19 +5213,13 @@ mod tests {
|
||||
// (could be the caret line, the parse-error detail
|
||||
// line, or the usage line). Scan for the friendly
|
||||
// "unknown mode" anchor phrase.
|
||||
let anywhere = app
|
||||
.output
|
||||
.iter()
|
||||
.any(|l| l.text.contains("unknown mode"));
|
||||
let anywhere = app.output.iter().any(|l| l.text.contains("unknown mode"));
|
||||
assert!(
|
||||
anywhere,
|
||||
"expected 'unknown mode' somewhere in output: {:?}",
|
||||
app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
|
||||
);
|
||||
let any_error = app
|
||||
.output
|
||||
.iter()
|
||||
.any(|l| l.kind == OutputKind::Error);
|
||||
let any_error = app.output.iter().any(|l| l.kind == OutputKind::Error);
|
||||
assert!(any_error, "expected at least one Error line");
|
||||
}
|
||||
|
||||
@@ -5319,11 +5309,17 @@ mod tests {
|
||||
app.schema_cache.tables = vec!["Orders".into(), "Customers".into()];
|
||||
app.schema_cache.table_columns.insert(
|
||||
"Orders".into(),
|
||||
vec![TableColumn::new("id", Type::Serial), TableColumn::new("customer_id", Type::Int)],
|
||||
vec![
|
||||
TableColumn::new("id", Type::Serial),
|
||||
TableColumn::new("customer_id", Type::Int),
|
||||
],
|
||||
);
|
||||
app.schema_cache.table_columns.insert(
|
||||
"Customers".into(),
|
||||
vec![TableColumn::new("id", Type::Serial), TableColumn::new("name", Type::Text)],
|
||||
vec![
|
||||
TableColumn::new("id", Type::Serial),
|
||||
TableColumn::new("name", Type::Text),
|
||||
],
|
||||
);
|
||||
for t in app.schema_cache.tables.clone() {
|
||||
for c in &app.schema_cache.table_columns[&t] {
|
||||
@@ -5484,10 +5480,7 @@ mod tests {
|
||||
detail: "SCAN Customers".to_string(),
|
||||
}],
|
||||
};
|
||||
app.update(AppEvent::DslExplainSucceeded {
|
||||
command: cmd,
|
||||
plan,
|
||||
});
|
||||
app.update(AppEvent::DslExplainSucceeded { command: cmd, plan });
|
||||
// ADR-0040: no `[ok] explain …` header — the (no-echo here)
|
||||
// command's success shows via the marker; the plan output
|
||||
// itself carries the content.
|
||||
@@ -5543,7 +5536,11 @@ mod tests {
|
||||
.iter()
|
||||
.find(|l| l.kind == OutputKind::Echo)
|
||||
.expect("dispatch pushed an echo");
|
||||
assert_eq!(echo.status, Some(EchoStatus::Pending), "pending before result");
|
||||
assert_eq!(
|
||||
echo.status,
|
||||
Some(EchoStatus::Pending),
|
||||
"pending before result"
|
||||
);
|
||||
app.update(AppEvent::DslSucceeded {
|
||||
command: Command::CreateTable {
|
||||
name: "T".to_string(),
|
||||
@@ -5633,8 +5630,14 @@ mod tests {
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("[ok] replay"), "summary present:\n{text}");
|
||||
assert!(text.contains("import a.zip"), "import skip warning rendered:\n{text}");
|
||||
assert!(text.contains("nested `replay x`"), "nested-replay skip warning rendered:\n{text}");
|
||||
assert!(
|
||||
text.contains("import a.zip"),
|
||||
"import skip warning rendered:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("nested `replay x`"),
|
||||
"nested-replay skip warning rendered:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5730,13 +5733,36 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hint_command_parses_to_app_hint() {
|
||||
use crate::dsl::{parse_command, AppCommand, Command};
|
||||
use crate::dsl::{AppCommand, Command, parse_command};
|
||||
assert!(matches!(
|
||||
parse_command("hint"),
|
||||
Ok(Command::App(AppCommand::Hint))
|
||||
));
|
||||
}
|
||||
|
||||
// ── ADR-0054: in-app `version` command ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn version_command_parses_to_app_version() {
|
||||
use crate::dsl::{AppCommand, Command, parse_command};
|
||||
assert!(matches!(
|
||||
parse_command("version"),
|
||||
Ok(Command::App(AppCommand::Version))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_command_emits_the_cargo_version() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "version");
|
||||
submit(&mut app);
|
||||
assert!(
|
||||
output_contains(&app, env!("CARGO_PKG_VERSION")),
|
||||
"in-app `version` should print CARGO_PKG_VERSION: {}",
|
||||
error_lines(&app),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_command_with_no_recent_error_shows_getting_started() {
|
||||
let mut app = App::new();
|
||||
@@ -5772,7 +5798,10 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "show ");
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert!(app.last_completion.is_some(), "precondition: Tab sets the memo");
|
||||
assert!(
|
||||
app.last_completion.is_some(),
|
||||
"precondition: Tab sets the memo"
|
||||
);
|
||||
let input = app.input.clone();
|
||||
f1(&mut app);
|
||||
assert!(app.last_completion.is_some(), "F1 must not clear the memo");
|
||||
@@ -5798,8 +5827,14 @@ mod tests {
|
||||
let input = app.input.clone();
|
||||
let before = app.output.len();
|
||||
app.update(ctrl_g());
|
||||
assert_eq!(app.input, input, "Ctrl-G must not change the buffer (no `g` typed)");
|
||||
assert!(app.output.len() > before, "Ctrl-G must emit the same hint F1 does");
|
||||
assert_eq!(
|
||||
app.input, input,
|
||||
"Ctrl-G must not change the buffer (no `g` typed)"
|
||||
);
|
||||
assert!(
|
||||
app.output.len() > before,
|
||||
"Ctrl-G must emit the same hint F1 does"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5826,7 +5861,11 @@ mod tests {
|
||||
let before = app.output.len();
|
||||
app.update(ctrl_g());
|
||||
assert_eq!(app.input, input, "Ctrl-G must not insert a `g`");
|
||||
assert_eq!(app.output.len(), before, "Ctrl-G does nothing when demo mode is off");
|
||||
assert_eq!(
|
||||
app.output.len(),
|
||||
before,
|
||||
"Ctrl-G does nothing when demo mode is off"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5920,7 +5959,10 @@ mod tests {
|
||||
#[test]
|
||||
fn f1_on_add_relationship_renders_the_relationship_block() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "add 1:n relationship from Customers.id to Orders.cust ");
|
||||
type_str(
|
||||
&mut app,
|
||||
"add 1:n relationship from Customers.id to Orders.cust ",
|
||||
);
|
||||
f1(&mut app);
|
||||
assert!(
|
||||
output_contains(&app, "one parent, many children"),
|
||||
@@ -6113,14 +6155,8 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
let cmd = Command::Update {
|
||||
table: "Customers".to_string(),
|
||||
assignments: vec![(
|
||||
"id".to_string(),
|
||||
crate::dsl::Value::Number("7".to_string()),
|
||||
)],
|
||||
filter: crate::dsl::RowFilter::eq(
|
||||
"name",
|
||||
crate::dsl::Value::Text("Bob".to_string()),
|
||||
),
|
||||
assignments: vec![("id".to_string(), crate::dsl::Value::Number("7".to_string()))],
|
||||
filter: crate::dsl::RowFilter::eq("name", crate::dsl::Value::Text("Bob".to_string())),
|
||||
};
|
||||
let err = crate::db::DbError::Sqlite {
|
||||
message: "UNIQUE constraint failed: Customers.id".to_string(),
|
||||
@@ -6680,7 +6716,10 @@ mod tests {
|
||||
app.update(key(KeyCode::Backspace));
|
||||
let actions = app.update(key(KeyCode::Enter));
|
||||
assert_eq!(app.input, "select", "input untouched in navigation mode");
|
||||
assert!(actions.is_empty(), "Enter does not submit in navigation mode");
|
||||
assert!(
|
||||
actions.is_empty(),
|
||||
"Enter does not submit in navigation mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6691,7 +6730,10 @@ mod tests {
|
||||
app.update(key(KeyCode::Down));
|
||||
app.update(key(KeyCode::Down));
|
||||
assert_eq!(app.tables_scroll, 2);
|
||||
assert_eq!(app.relationships_scroll, 0, "only the focused panel scrolls");
|
||||
assert_eq!(
|
||||
app.relationships_scroll, 0,
|
||||
"only the focused panel scrolls"
|
||||
);
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(app.tables_scroll, 1);
|
||||
// Up saturates at the top.
|
||||
@@ -6969,7 +7011,8 @@ mod tests {
|
||||
for round in 0..3 {
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(
|
||||
app.input, "insert into Thing values (1)",
|
||||
app.input,
|
||||
"insert into Thing values (1)",
|
||||
"Up #{} should recall the newest entry",
|
||||
round + 1,
|
||||
);
|
||||
@@ -7191,8 +7234,7 @@ mod tests {
|
||||
has_default: false,
|
||||
}],
|
||||
);
|
||||
app.input =
|
||||
"select * from products where price like 5".to_string();
|
||||
app.input = "select * from products where price like 5".to_string();
|
||||
assert_eq!(
|
||||
app.input_validity_verdict(),
|
||||
Some(crate::dsl::walker::Severity::Warning),
|
||||
@@ -7321,7 +7363,9 @@ mod tests {
|
||||
"directly-deleted count surfaced: {texts:?}",
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`")
|
||||
texts
|
||||
.iter()
|
||||
.any(|t| t.contains("2 row(s) deleted in `Orders`")
|
||||
&& t.contains("relationship `places`")),
|
||||
"per-relationship cascade summary surfaced: {texts:?}",
|
||||
);
|
||||
@@ -7357,11 +7401,15 @@ mod tests {
|
||||
});
|
||||
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
|
||||
assert!(
|
||||
texts.iter().any(|t| t.contains("20 row(s) seeded into users")),
|
||||
texts
|
||||
.iter()
|
||||
.any(|t| t.contains("20 row(s) seeded into users")),
|
||||
"seeded-row count surfaced: {texts:?}",
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|t| t.contains("status") && t.contains("generic text")),
|
||||
texts
|
||||
.iter()
|
||||
.any(|t| t.contains("status") && t.contains("generic text")),
|
||||
"the advisory names the enum-ish column: {texts:?}",
|
||||
);
|
||||
}
|
||||
@@ -7395,8 +7443,9 @@ mod tests {
|
||||
});
|
||||
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
|
||||
assert!(
|
||||
texts.iter().any(|t| t.contains("4 row(s) seeded into J")
|
||||
&& t.contains("of 10 requested")),
|
||||
texts
|
||||
.iter()
|
||||
.any(|t| t.contains("4 row(s) seeded into J") && t.contains("of 10 requested")),
|
||||
"the cap note surfaces requested vs produced: {texts:?}",
|
||||
);
|
||||
}
|
||||
@@ -7435,7 +7484,9 @@ mod tests {
|
||||
});
|
||||
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
|
||||
assert!(
|
||||
texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`")),
|
||||
texts
|
||||
.iter()
|
||||
.any(|t| t.contains("2 row(s) deleted in `Orders`")),
|
||||
"cascade summary still surfaces alongside RETURNING: {texts:?}",
|
||||
);
|
||||
assert!(
|
||||
|
||||
+34
-34
@@ -30,9 +30,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
use tracing::{debug, info};
|
||||
use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
|
||||
|
||||
use crate::project::{
|
||||
HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local,
|
||||
};
|
||||
use crate::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local};
|
||||
|
||||
/// File names excluded from `export` zips. These are either
|
||||
/// derived (`playground.db`), per-process (`.lock`),
|
||||
@@ -118,20 +116,14 @@ impl std::fmt::Display for ArchiveError {
|
||||
limit = format_args!("{limit:02}"),
|
||||
))
|
||||
}
|
||||
Self::InvalidZip(detail) => f.write_str(&crate::t!(
|
||||
"archive.invalid_zip",
|
||||
detail = detail,
|
||||
)),
|
||||
Self::NotAProjectArchive => {
|
||||
f.write_str(&crate::t!("archive.not_a_project_archive"))
|
||||
Self::InvalidZip(detail) => {
|
||||
f.write_str(&crate::t!("archive.invalid_zip", detail = detail,))
|
||||
}
|
||||
Self::MultipleTopFolders => {
|
||||
f.write_str(&crate::t!("archive.multiple_top_folders"))
|
||||
Self::NotAProjectArchive => f.write_str(&crate::t!("archive.not_a_project_archive")),
|
||||
Self::MultipleTopFolders => f.write_str(&crate::t!("archive.multiple_top_folders")),
|
||||
Self::UnsafeEntry(entry) => {
|
||||
f.write_str(&crate::t!("archive.unsafe_entry", entry = entry,))
|
||||
}
|
||||
Self::UnsafeEntry(entry) => f.write_str(&crate::t!(
|
||||
"archive.unsafe_entry",
|
||||
entry = entry,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,13 +208,7 @@ pub fn export_project(
|
||||
.unix_permissions(0o644);
|
||||
|
||||
add_directory_entry(&mut writer, project_name, dst_zip)?;
|
||||
add_directory_recursive(
|
||||
&mut writer,
|
||||
project_path,
|
||||
project_name,
|
||||
&options,
|
||||
dst_zip,
|
||||
)?;
|
||||
add_directory_recursive(&mut writer, project_path, project_name, &options, dst_zip)?;
|
||||
|
||||
writer.finish().map_err(|e| ArchiveError::Zip {
|
||||
path: dst_zip.to_path_buf(),
|
||||
@@ -392,10 +378,7 @@ pub struct ZipInspection {
|
||||
///
|
||||
/// Returns the resolved target path and the suffix that was
|
||||
/// applied (0 if the original name was free, 2..=99 otherwise).
|
||||
pub fn resolve_import_target(
|
||||
parent: &Path,
|
||||
name: &str,
|
||||
) -> Result<(PathBuf, u32), ArchiveError> {
|
||||
pub fn resolve_import_target(parent: &Path, name: &str) -> Result<(PathBuf, u32), ArchiveError> {
|
||||
let direct = parent.join(name);
|
||||
if !direct.exists() {
|
||||
return Ok((direct, 0));
|
||||
@@ -495,7 +478,9 @@ pub fn extract_into(
|
||||
source,
|
||||
})?;
|
||||
let mut buf = Vec::with_capacity(entry.size() as usize);
|
||||
entry.read_to_end(&mut buf).map_err(|source| ArchiveError::Io {
|
||||
entry
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|source| ArchiveError::Io {
|
||||
path: dst_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
@@ -523,7 +508,11 @@ mod tests {
|
||||
fs::write(p.join(PROJECT_YAML), "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n").unwrap();
|
||||
fs::create_dir_all(p.join("data")).unwrap();
|
||||
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
|
||||
fs::write(p.join(HISTORY_LOG), "T|ok|create table Customers with pk id(serial)\n").unwrap();
|
||||
fs::write(
|
||||
p.join(HISTORY_LOG),
|
||||
"T|ok|create table Customers with pk id(serial)\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(p.join(PLAYGROUND_DB), [0u8; 32]).unwrap();
|
||||
fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap();
|
||||
// Stray atomic-write staging file — must be excluded.
|
||||
@@ -536,7 +525,9 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
p.join(crate::undo::SNAPSHOTS_DIR).join("0").join(PLAYGROUND_DB),
|
||||
p.join(crate::undo::SNAPSHOTS_DIR)
|
||||
.join("0")
|
||||
.join(PLAYGROUND_DB),
|
||||
[0u8; 16],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -618,11 +609,15 @@ mod tests {
|
||||
let zip_path = tmp.path().join("notaproject.zip");
|
||||
let f = fs::File::create(&zip_path).unwrap();
|
||||
let mut w = ZipWriter::new(f);
|
||||
w.start_file("foo/bar.txt", SimpleFileOptions::default()).unwrap();
|
||||
w.start_file("foo/bar.txt", SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
w.write_all(b"hi").unwrap();
|
||||
w.finish().unwrap();
|
||||
let err = inspect_zip(&zip_path).expect_err("must refuse");
|
||||
assert!(matches!(err, ArchiveError::NotAProjectArchive), "got: {err:?}");
|
||||
assert!(
|
||||
matches!(err, ArchiveError::NotAProjectArchive),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -631,13 +626,18 @@ mod tests {
|
||||
let zip_path = tmp.path().join("multi.zip");
|
||||
let f = fs::File::create(&zip_path).unwrap();
|
||||
let mut w = ZipWriter::new(f);
|
||||
w.start_file("a/project.yaml", SimpleFileOptions::default()).unwrap();
|
||||
w.start_file("a/project.yaml", SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
w.write_all(b"x").unwrap();
|
||||
w.start_file("b/project.yaml", SimpleFileOptions::default()).unwrap();
|
||||
w.start_file("b/project.yaml", SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
w.write_all(b"x").unwrap();
|
||||
w.finish().unwrap();
|
||||
let err = inspect_zip(&zip_path).expect_err("must refuse");
|
||||
assert!(matches!(err, ArchiveError::MultipleTopFolders), "got: {err:?}");
|
||||
assert!(
|
||||
matches!(err, ArchiveError::MultipleTopFolders),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+93
-24
@@ -30,6 +30,10 @@ pub struct Args {
|
||||
/// `--help` / `-h`: print usage to stdout and exit. The
|
||||
/// runtime checks this flag before doing any other work.
|
||||
pub help: bool,
|
||||
/// `--version` / `-V`: print the version (`CARGO_PKG_VERSION`,
|
||||
/// the single source of truth — ADR-0054) and exit. Checked
|
||||
/// alongside `--help` before any other work.
|
||||
pub version: bool,
|
||||
/// `--no-undo`: disable the auto-snapshot / undo machinery for
|
||||
/// this run (ADR-0006 Amendment 1). When set, no snapshots are
|
||||
/// taken — zero per-command overhead — and `undo` / `redo`
|
||||
@@ -62,6 +66,17 @@ pub fn help_text() -> String {
|
||||
crate::t!("help.cli_banner")
|
||||
}
|
||||
|
||||
/// Version line printed by `--version` / `-V` and the in-app `version`
|
||||
/// command (ADR-0054).
|
||||
///
|
||||
/// `CARGO_PKG_VERSION` is the single source of truth — it equals the `v*`
|
||||
/// release tag (the release CI guards that), so what the binary reports
|
||||
/// always matches the downloaded artifact.
|
||||
#[must_use]
|
||||
pub fn version_text() -> String {
|
||||
crate::t!("cli.version_line", version = env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ArgsError {
|
||||
MissingValue(&'static str),
|
||||
@@ -81,10 +96,7 @@ pub enum ArgsError {
|
||||
impl std::fmt::Display for ArgsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingValue(flag) => f.write_str(&crate::t!(
|
||||
"cli.missing_value",
|
||||
flag = flag,
|
||||
)),
|
||||
Self::MissingValue(flag) => f.write_str(&crate::t!("cli.missing_value", flag = flag,)),
|
||||
Self::InvalidValue {
|
||||
flag,
|
||||
value,
|
||||
@@ -95,10 +107,7 @@ impl std::fmt::Display for ArgsError {
|
||||
value = value,
|
||||
expected = expected,
|
||||
)),
|
||||
Self::Unknown(arg) => f.write_str(&crate::t!(
|
||||
"cli.unknown_argument",
|
||||
arg = arg,
|
||||
)),
|
||||
Self::Unknown(arg) => f.write_str(&crate::t!("cli.unknown_argument", arg = arg,)),
|
||||
Self::MultiplePaths { first, second } => f.write_str(&crate::t!(
|
||||
"cli.multiple_paths",
|
||||
first = first,
|
||||
@@ -129,6 +138,7 @@ impl Args {
|
||||
let mut project_path: Option<PathBuf> = None;
|
||||
let mut resume = false;
|
||||
let mut help = false;
|
||||
let mut version = false;
|
||||
let mut no_undo = false;
|
||||
let mut mode: Option<Mode> = None;
|
||||
// Demonstration mode (ADR-0047): the env var is the default,
|
||||
@@ -143,6 +153,9 @@ impl Args {
|
||||
"--help" | "-h" => {
|
||||
help = true;
|
||||
}
|
||||
"--version" | "-V" => {
|
||||
version = true;
|
||||
}
|
||||
"--resume" => {
|
||||
resume = true;
|
||||
}
|
||||
@@ -208,6 +221,7 @@ impl Args {
|
||||
project_path,
|
||||
resume,
|
||||
help,
|
||||
version,
|
||||
no_undo,
|
||||
mode,
|
||||
demo,
|
||||
@@ -241,7 +255,11 @@ fn default_theme() -> Theme {
|
||||
// Standard convention: 0..=6 and 8 are dark backgrounds,
|
||||
// 7 and 9..=15 are light. ITerm emits 15 for white-ish.
|
||||
let is_dark = matches!(code, 0..=6 | 8);
|
||||
return if is_dark { Theme::dark() } else { Theme::light() };
|
||||
return if is_dark {
|
||||
Theme::dark()
|
||||
} else {
|
||||
Theme::light()
|
||||
};
|
||||
}
|
||||
Theme::default()
|
||||
}
|
||||
@@ -294,10 +312,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mode_flag_simple_and_advanced() {
|
||||
assert_eq!(Args::parse(["--mode", "simple"]).unwrap().mode, Some(Mode::Simple));
|
||||
assert_eq!(Args::parse(["--mode", "advanced"]).unwrap().mode, Some(Mode::Advanced));
|
||||
assert_eq!(
|
||||
Args::parse(["--mode", "simple"]).unwrap().mode,
|
||||
Some(Mode::Simple)
|
||||
);
|
||||
assert_eq!(
|
||||
Args::parse(["--mode", "advanced"]).unwrap().mode,
|
||||
Some(Mode::Advanced)
|
||||
);
|
||||
// Case-insensitive, like the `mode` command.
|
||||
assert_eq!(Args::parse(["--mode", "ADVANCED"]).unwrap().mode, Some(Mode::Advanced));
|
||||
assert_eq!(
|
||||
Args::parse(["--mode", "ADVANCED"]).unwrap().mode,
|
||||
Some(Mode::Advanced)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -330,7 +357,10 @@ mod tests {
|
||||
#[test]
|
||||
fn data_dir_flag_parses() {
|
||||
let args = Args::parse(["--data-dir", "/tmp/playground-data"]).unwrap();
|
||||
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/playground-data")));
|
||||
assert_eq!(
|
||||
args.data_dir.as_deref(),
|
||||
Some(std::path::Path::new("/tmp/playground-data"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -350,13 +380,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn data_dir_and_positional_can_coexist() {
|
||||
let args = Args::parse([
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"/home/me/MyProject",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/data")));
|
||||
let args = Args::parse(["--data-dir", "/tmp/data", "/home/me/MyProject"]).unwrap();
|
||||
assert_eq!(
|
||||
args.data_dir.as_deref(),
|
||||
Some(std::path::Path::new("/tmp/data"))
|
||||
);
|
||||
assert_eq!(
|
||||
args.project_path.as_deref(),
|
||||
Some(std::path::Path::new("/home/me/MyProject"))
|
||||
@@ -366,7 +394,10 @@ mod tests {
|
||||
#[test]
|
||||
fn two_positional_paths_error() {
|
||||
let err = Args::parse(["/a", "/b"]).unwrap_err();
|
||||
assert!(matches!(err, ArgsError::MultiplePaths { .. }), "got: {err:?}");
|
||||
assert!(
|
||||
matches!(err, ArgsError::MultiplePaths { .. }),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -435,7 +466,10 @@ mod tests {
|
||||
// Absent `--demo` (and absent env var in the test runner),
|
||||
// demo mode is off — zero footprint for real users.
|
||||
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
|
||||
assert!(!args.demo, "demo is off unless --demo or the env var is given");
|
||||
assert!(
|
||||
!args.demo,
|
||||
"demo is off unless --demo or the env var is given"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -464,7 +498,10 @@ mod tests {
|
||||
}
|
||||
// Disabling values.
|
||||
for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] {
|
||||
assert!(!demo_value_is_truthy(v), "{v:?} should not enable demo mode");
|
||||
assert!(
|
||||
!demo_value_is_truthy(v),
|
||||
"{v:?} should not enable demo mode"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +510,38 @@ mod tests {
|
||||
// Make sure the path-vs-flag distinction is robust:
|
||||
// unknown flags don't get silently swallowed as paths.
|
||||
let err = Args::parse(["--bogus", "/some/path"]).unwrap_err();
|
||||
assert!(matches!(&err, ArgsError::Unknown(s) if s == "--bogus"), "got: {err:?}");
|
||||
assert!(
|
||||
matches!(&err, ArgsError::Unknown(s) if s == "--bogus"),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- ADR-0054: --version / -V ----
|
||||
|
||||
#[test]
|
||||
fn version_long_flag_parses() {
|
||||
assert!(Args::parse(["--version"]).unwrap().version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_short_flag_parses() {
|
||||
assert!(Args::parse(["-V"]).unwrap().version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_defaults_off() {
|
||||
assert!(!Args::parse(std::iter::empty::<&str>()).unwrap().version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_text_carries_the_cargo_version() {
|
||||
// The binary's self-reported version IS Cargo.toml's (the
|
||||
// single source of truth, ADR-0054) — and the release CI guards
|
||||
// that the `v*` tag equals it.
|
||||
let text = version_text();
|
||||
assert!(
|
||||
text.contains(env!("CARGO_PKG_VERSION")),
|
||||
"version line should embed CARGO_PKG_VERSION; got {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+202
-158
@@ -15,10 +15,10 @@
|
||||
//! `app.rs`; this module owns the candidate computation.
|
||||
|
||||
use crate::dsl::grammar::IdentSource;
|
||||
use crate::dsl::parser::parse_command_with_schema_in_mode;
|
||||
use crate::dsl::types::Type;
|
||||
use crate::dsl::walker::outcome::Expectation;
|
||||
use crate::dsl::{ParseError, parse_command};
|
||||
use crate::dsl::parser::parse_command_with_schema_in_mode;
|
||||
use crate::mode::Mode;
|
||||
|
||||
/// Composite literal candidates whose lexed shape is more than
|
||||
@@ -275,11 +275,7 @@ pub struct Completion {
|
||||
/// (case-insensitive starts-with), combined, sorted, and
|
||||
/// deduplicated.
|
||||
#[must_use]
|
||||
pub fn candidates_at_cursor(
|
||||
input: &str,
|
||||
cursor: usize,
|
||||
cache: &SchemaCache,
|
||||
) -> Option<Completion> {
|
||||
pub fn candidates_at_cursor(input: &str, cursor: usize, cache: &SchemaCache) -> Option<Completion> {
|
||||
candidates_at_cursor_in_mode(input, cursor, cache, Mode::Advanced)
|
||||
}
|
||||
|
||||
@@ -358,7 +354,11 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
let word_boundary = run == 0 || bytes[run - 1].is_ascii_whitespace();
|
||||
if run < cursor && bytes[run] == b'-' && word_boundary && run < start {
|
||||
let pre = crate::dsl::walker::completion_probe_in_mode(&input[..run], cache, mode);
|
||||
if pre.expected.iter().any(|e| matches!(e, Expectation::Flag(_))) {
|
||||
if pre
|
||||
.expected
|
||||
.iter()
|
||||
.any(|e| matches!(e, Expectation::Flag(_)))
|
||||
{
|
||||
start = run;
|
||||
}
|
||||
}
|
||||
@@ -473,14 +473,11 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
// walk's `current_table_columns`; fall back to "the union of
|
||||
// the look-ahead from_scope's bindings' columns" when leading
|
||||
// produced no in-scope columns. Phase-1 DSL paths unaffected.
|
||||
let lookahead_union_columns: Vec<TableColumn> =
|
||||
if probe.current_table_columns.is_none() {
|
||||
let lookahead_union_columns: Vec<TableColumn> = if probe.current_table_columns.is_none() {
|
||||
let mut out: Vec<TableColumn> = Vec::new();
|
||||
for binding in resolution_from_scope {
|
||||
for col in &binding.columns {
|
||||
if !out.iter().any(|c| {
|
||||
c.name.eq_ignore_ascii_case(&col.name)
|
||||
}) {
|
||||
if !out.iter().any(|c| c.name.eq_ignore_ascii_case(&col.name)) {
|
||||
out.push(col.clone());
|
||||
}
|
||||
}
|
||||
@@ -507,9 +504,7 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
// column list (the structural error path surfaces the
|
||||
// unresolved-prefix message).
|
||||
let prefix_qualifier = peek_back_qualifier(input, start);
|
||||
let qualified_columns: Option<Vec<String>> = prefix_qualifier
|
||||
.as_ref()
|
||||
.map(|q| {
|
||||
let qualified_columns: Option<Vec<String>> = prefix_qualifier.as_ref().map(|q| {
|
||||
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON
|
||||
// CONFLICT … DO UPDATE` completes to the target table's
|
||||
// columns — `excluded` mirrors the would-be-inserted row.
|
||||
@@ -523,12 +518,7 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
{
|
||||
cols.iter().map(|c| c.name.clone()).collect()
|
||||
} else {
|
||||
resolve_qualifier_columns_in(
|
||||
q,
|
||||
resolution_from_scope,
|
||||
resolution_cte_bindings,
|
||||
cache,
|
||||
)
|
||||
resolve_qualifier_columns_in(q, resolution_from_scope, resolution_cte_bindings, cache)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -574,8 +564,7 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
Some(crate::dsl::grammar::HintMode::ProseOnly(_))
|
||||
);
|
||||
if partial_prefix.is_empty()
|
||||
&& (prose_only_slot
|
||||
|| (is_value_literal_signature(&expected) && !has_schema_ident))
|
||||
&& (prose_only_slot || (is_value_literal_signature(&expected) && !has_schema_ident))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -646,7 +635,13 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
// shortid). The walker surfaces this as
|
||||
// `Expectation::Ident { source: Types }`.
|
||||
let type_names: Vec<String> = if expected.iter().any(|e| {
|
||||
matches!(e, Expectation::Ident { source: IdentSource::Types, .. })
|
||||
matches!(
|
||||
e,
|
||||
Expectation::Ident {
|
||||
source: IdentSource::Types,
|
||||
..
|
||||
}
|
||||
)
|
||||
}) {
|
||||
Type::all()
|
||||
.iter()
|
||||
@@ -725,7 +720,13 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
// filtered like every other source; empty prefix offers the whole
|
||||
// set. Tagged `CandidateKind::Function` for its own colour.
|
||||
let has_sql_expr_slot = expected.iter().any(|e| {
|
||||
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
|
||||
matches!(
|
||||
e,
|
||||
Expectation::Ident {
|
||||
role: "sql_expr_ident",
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
let mut functions: Vec<String> = if has_sql_expr_slot {
|
||||
crate::dsl::sql_functions::KNOWN_SQL_FUNCTIONS
|
||||
@@ -741,9 +742,15 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
// curated vocabulary is offered so a learner can discover `email` /
|
||||
// `product` / … by Tab. Same `Function` kind / `tok_function` colour
|
||||
// as SQL functions (no new theme colour — ADR-0048 §Grammar).
|
||||
let has_generator_slot = expected
|
||||
.iter()
|
||||
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. }));
|
||||
let has_generator_slot = expected.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
Expectation::Ident {
|
||||
source: IdentSource::Generators,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
if has_generator_slot {
|
||||
functions.extend(
|
||||
crate::seed::KNOWN_GENERATORS
|
||||
@@ -765,8 +772,7 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
// (the `typing_over_diag` path) — keeps the alias from flashing as
|
||||
// a bogus "unknown column" while typing. Mixed into `identifiers`
|
||||
// so it sorts/dedups/colours uniformly with column candidates.
|
||||
let alias_candidates: Vec<String> =
|
||||
if has_sql_expr_slot && prefix_qualifier.is_none() {
|
||||
let alias_candidates: Vec<String> = if has_sql_expr_slot && prefix_qualifier.is_none() {
|
||||
// Once the partial *exactly* matches an in-scope qualifier,
|
||||
// discoverability is served — the learner has a whole alias
|
||||
// in hand and now needs the "add `.column`" hint
|
||||
@@ -784,8 +790,7 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
} else {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for binding in resolution_from_scope {
|
||||
let qualifier =
|
||||
binding.alias.as_deref().unwrap_or(binding.table.as_str());
|
||||
let qualifier = binding.alias.as_deref().unwrap_or(binding.table.as_str());
|
||||
if matches_prefix(qualifier)
|
||||
&& !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier))
|
||||
{
|
||||
@@ -811,9 +816,7 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
let mut identifiers: Vec<String> = expected
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Expectation::Ident { source, .. } if source.completes_from_schema() => {
|
||||
Some(*source)
|
||||
}
|
||||
Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
|
||||
_ => None,
|
||||
})
|
||||
.flat_map(|source| {
|
||||
@@ -1007,11 +1010,7 @@ fn resolve_qualifier_columns_in(
|
||||
.iter()
|
||||
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
|
||||
{
|
||||
return cte
|
||||
.columns
|
||||
.iter()
|
||||
.filter_map(|c| c.name.clone())
|
||||
.collect();
|
||||
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
|
||||
}
|
||||
}
|
||||
// Second: table-name match in the active from_scope.
|
||||
@@ -1026,11 +1025,7 @@ fn resolve_qualifier_columns_in(
|
||||
.iter()
|
||||
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
|
||||
{
|
||||
return cte
|
||||
.columns
|
||||
.iter()
|
||||
.filter_map(|c| c.name.clone())
|
||||
.collect();
|
||||
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
|
||||
}
|
||||
}
|
||||
// Third: direct cte_bindings match (cte_alias.|).
|
||||
@@ -1038,11 +1033,7 @@ fn resolve_qualifier_columns_in(
|
||||
.iter()
|
||||
.find(|c| c.name.eq_ignore_ascii_case(qualifier))
|
||||
{
|
||||
return cte
|
||||
.columns
|
||||
.iter()
|
||||
.filter_map(|c| c.name.clone())
|
||||
.collect();
|
||||
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
|
||||
}
|
||||
// Fourth: a bare table name from the schema cache — DSL
|
||||
// paths reach this for `from <Table>.<col>` shapes where
|
||||
@@ -1287,7 +1278,13 @@ pub fn invalid_ident_at_cursor_in_mode(
|
||||
// column. So `select Agx` warns at typing time again, while
|
||||
// `select sum` does not.
|
||||
let has_sql_expr_slot = expected.iter().any(|e| {
|
||||
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
|
||||
matches!(
|
||||
e,
|
||||
Expectation::Ident {
|
||||
role: "sql_expr_ident",
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
if has_sql_expr_slot && crate::dsl::sql_functions::is_known_function_prefix(partial) {
|
||||
return None;
|
||||
@@ -1318,9 +1315,15 @@ pub fn invalid_ident_at_cursor_in_mode(
|
||||
// schema-column check below would never see it. A partial that
|
||||
// prefix-matches a known generator is an in-progress name; anything
|
||||
// else is an unknown generator → flag it `[ERR]` while typing.
|
||||
let has_generator_slot = expected
|
||||
.iter()
|
||||
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. }));
|
||||
let has_generator_slot = expected.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
Expectation::Ident {
|
||||
source: IdentSource::Generators,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
if has_generator_slot {
|
||||
if crate::seed::is_known_generator_prefix(partial) {
|
||||
return None;
|
||||
@@ -1335,9 +1338,7 @@ pub fn invalid_ident_at_cursor_in_mode(
|
||||
let sources: Vec<IdentSource> = expected
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Expectation::Ident { source, .. } if source.completes_from_schema() => {
|
||||
Some(*source)
|
||||
}
|
||||
Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -1412,13 +1413,15 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn cands(input: &str, cursor: usize) -> Vec<String> {
|
||||
candidates_at_cursor(input, cursor, &SchemaCache::default())
|
||||
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
|
||||
candidates_at_cursor(input, cursor, &SchemaCache::default()).map_or_else(Vec::new, |c| {
|
||||
c.candidates.into_iter().map(|c| c.text).collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec<String> {
|
||||
candidates_at_cursor(input, cursor, cache)
|
||||
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
|
||||
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
|
||||
c.candidates.into_iter().map(|c| c.text).collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Simple-mode completion candidates — the DSL surface
|
||||
@@ -1429,7 +1432,9 @@ mod tests {
|
||||
/// Advanced mode surfaces the SQL grammar's completions instead.
|
||||
fn cands_simple(input: &str, cursor: usize) -> Vec<String> {
|
||||
candidates_at_cursor_in_mode(input, cursor, &SchemaCache::default(), Mode::Simple)
|
||||
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
|
||||
.map_or_else(Vec::new, |c| {
|
||||
c.candidates.into_iter().map(|c| c.text).collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn cand_kinds_with(
|
||||
@@ -1438,10 +1443,7 @@ mod tests {
|
||||
cache: &SchemaCache,
|
||||
) -> Vec<(String, CandidateKind)> {
|
||||
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
|
||||
c.candidates
|
||||
.into_iter()
|
||||
.map(|c| (c.text, c.kind))
|
||||
.collect()
|
||||
c.candidates.into_iter().map(|c| (c.text, c.kind)).collect()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1503,12 +1505,21 @@ mod tests {
|
||||
// Simple-only (column, relationship, constraint).
|
||||
let cs = cands("drop ", 5);
|
||||
for kw in ["table", "index", "column", "relationship", "constraint"] {
|
||||
assert!(cs.contains(&kw.to_string()), "`drop ` should offer `{kw}`; got {cs:?}");
|
||||
assert!(
|
||||
cs.contains(&kw.to_string()),
|
||||
"`drop ` should offer `{kw}`; got {cs:?}"
|
||||
);
|
||||
}
|
||||
// Both-mode continuations block before the simple-only ones.
|
||||
let pos = |k: &str| cs.iter().position(|c| c == k).unwrap();
|
||||
assert!(pos("table") < pos("column"), "Both block precedes Simple block: {cs:?}");
|
||||
assert!(pos("index") < pos("relationship"), "Both block precedes Simple block: {cs:?}");
|
||||
assert!(
|
||||
pos("table") < pos("column"),
|
||||
"Both block precedes Simple block: {cs:?}"
|
||||
);
|
||||
assert!(
|
||||
pos("index") < pos("relationship"),
|
||||
"Both block precedes Simple block: {cs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1631,8 +1642,14 @@ mod tests {
|
||||
let c = candidates_at_cursor(input, input.len(), &SchemaCache::default())
|
||||
.expect("a `-` at a flag position offers candidates");
|
||||
let texts: Vec<&str> = c.candidates.iter().map(|x| x.text.as_str()).collect();
|
||||
assert!(texts.contains(&"--create-fk"), "should offer --create-fk: {texts:?}");
|
||||
assert!(!texts.contains(&"on"), "must NOT offer `on` after a dash: {texts:?}");
|
||||
assert!(
|
||||
texts.contains(&"--create-fk"),
|
||||
"should offer --create-fk: {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
!texts.contains(&"on"),
|
||||
"must NOT offer `on` after a dash: {texts:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
c.replaced_range,
|
||||
(input.len() - 1, input.len()),
|
||||
@@ -1643,12 +1660,8 @@ mod tests {
|
||||
#[test]
|
||||
fn double_dash_replaces_both_dashes_on_accept() {
|
||||
let input = "delete from T --";
|
||||
let c = candidates_at_cursor_in_mode(
|
||||
input,
|
||||
input.len(),
|
||||
&SchemaCache::default(),
|
||||
Mode::Simple,
|
||||
)
|
||||
let c =
|
||||
candidates_at_cursor_in_mode(input, input.len(), &SchemaCache::default(), Mode::Simple)
|
||||
.expect("`--` offers the flag");
|
||||
assert!(c.candidates.iter().any(|x| x.text == "--all-rows"));
|
||||
assert_eq!(
|
||||
@@ -1668,9 +1681,7 @@ mod tests {
|
||||
s.tables.push("T".into());
|
||||
s.columns.push("x".into());
|
||||
let input = "show data T where x = -5";
|
||||
if let Some(c) =
|
||||
candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple)
|
||||
{
|
||||
if let Some(c) = candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple) {
|
||||
assert!(
|
||||
!c.candidates.iter().any(|x| x.text.starts_with("--")),
|
||||
"no flags at a value position: {:?}",
|
||||
@@ -1715,8 +1726,8 @@ mod tests {
|
||||
// App-lifecycle commands now appear alongside DSL
|
||||
// commands in the entry-keyword set.
|
||||
for expected in &[
|
||||
"quit", "help", "rebuild", "save", "new", "load", "export",
|
||||
"import", "mode", "messages", "undo", "redo", "copy",
|
||||
"quit", "help", "rebuild", "save", "new", "load", "export", "import", "mode",
|
||||
"messages", "undo", "redo", "copy",
|
||||
] {
|
||||
assert!(
|
||||
cs.contains(&expected.to_string()),
|
||||
@@ -1943,7 +1954,10 @@ mod tests {
|
||||
// opening a sub-shape) becomes a Tab candidate.
|
||||
let input = "add column to table T";
|
||||
let cs = cands(input, input.len());
|
||||
assert!(cs.is_empty(), "trailing-content punct should not surface: {cs:?}");
|
||||
assert!(
|
||||
cs.is_empty(),
|
||||
"trailing-content punct should not surface: {cs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1957,10 +1971,7 @@ mod tests {
|
||||
assert!(cs.contains(&"(".to_string()), "got {cs:?}");
|
||||
}
|
||||
|
||||
fn schema_with_table(
|
||||
table: &str,
|
||||
columns: &[(&str, crate::dsl::types::Type)],
|
||||
) -> SchemaCache {
|
||||
fn schema_with_table(table: &str, columns: &[(&str, crate::dsl::types::Type)]) -> SchemaCache {
|
||||
let mut cache = SchemaCache::default();
|
||||
cache.tables.push(table.to_string());
|
||||
let cols: Vec<TableColumn> = columns
|
||||
@@ -2002,8 +2013,14 @@ mod tests {
|
||||
let cache = two_table_alias_cache();
|
||||
let input = "select a.id from a o join b z on o.id = z.id group by ";
|
||||
let cs = cands_with(input, input.len(), &cache);
|
||||
assert!(cs.contains(&"o".to_string()), "alias `o` must be offered; got {cs:?}");
|
||||
assert!(cs.contains(&"z".to_string()), "alias `z` must be offered; got {cs:?}");
|
||||
assert!(
|
||||
cs.contains(&"o".to_string()),
|
||||
"alias `o` must be offered; got {cs:?}"
|
||||
);
|
||||
assert!(
|
||||
cs.contains(&"z".to_string()),
|
||||
"alias `z` must be offered; got {cs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2015,8 +2032,14 @@ mod tests {
|
||||
let cache = two_table_alias_cache();
|
||||
let input = "select a.id from a aa join b ab on aa.id = ab.id group by a";
|
||||
let cs = cands_with(input, input.len(), &cache);
|
||||
assert!(cs.contains(&"aa".to_string()), "alias `aa` must be offered; got {cs:?}");
|
||||
assert!(cs.contains(&"ab".to_string()), "alias `ab` must be offered; got {cs:?}");
|
||||
assert!(
|
||||
cs.contains(&"aa".to_string()),
|
||||
"alias `aa` must be offered; got {cs:?}"
|
||||
);
|
||||
assert!(
|
||||
cs.contains(&"ab".to_string()),
|
||||
"alias `ab` must be offered; got {cs:?}"
|
||||
);
|
||||
|
||||
// Exact-alias partial: the alias source steps aside.
|
||||
let exact = "select aa.id from a aa join b ab on aa.id = ab.id group by aa";
|
||||
@@ -2046,19 +2069,20 @@ mod tests {
|
||||
// SchemaCache.columns has columns from many tables, but
|
||||
// at `update Customers set ` only Customers' columns
|
||||
// should appear.
|
||||
let mut cache = schema_with_table(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Email", Type::Text)],
|
||||
);
|
||||
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
|
||||
// Pretend the global flat list has columns from a second
|
||||
// table that aren't in Customers.
|
||||
cache.columns.push("OrderTotal".to_string());
|
||||
cache.columns.push("Stock".to_string());
|
||||
cache
|
||||
.table_columns
|
||||
.insert("Orders".to_string(), vec![
|
||||
TableColumn { name: "OrderTotal".to_string(), user_type: Type::Real, not_null: false, has_default: false },
|
||||
]);
|
||||
cache.table_columns.insert(
|
||||
"Orders".to_string(),
|
||||
vec![TableColumn {
|
||||
name: "OrderTotal".to_string(),
|
||||
user_type: Type::Real,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
}],
|
||||
);
|
||||
cache.tables.push("Orders".to_string());
|
||||
let cs = cands_with("update Customers set ", 21, &cache);
|
||||
// Customers's columns should appear:
|
||||
@@ -2079,10 +2103,7 @@ mod tests {
|
||||
// *before* ORDER BY (the FROM's JOIN options, WHERE /
|
||||
// GROUP BY / HAVING, set-ops). Those used to shove the
|
||||
// columns off-screen.
|
||||
let cache = schema_with_table(
|
||||
"Things",
|
||||
&[("Name", Type::Text), ("Qty", Type::Int)],
|
||||
);
|
||||
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
|
||||
let input = "select Name from Things order by ";
|
||||
let cs = cands_with(input, input.len(), &cache);
|
||||
// The columns the user wants are offered:
|
||||
@@ -2090,8 +2111,19 @@ mod tests {
|
||||
assert!(cs.contains(&"Qty".to_string()), "got {cs:?}");
|
||||
// Preceding-clause keywords must not leak in:
|
||||
for kw in [
|
||||
"where", "group", "having", "join", "union", "intersect",
|
||||
"except", "left", "right", "full", "cross", "inner", "as",
|
||||
"where",
|
||||
"group",
|
||||
"having",
|
||||
"join",
|
||||
"union",
|
||||
"intersect",
|
||||
"except",
|
||||
"left",
|
||||
"right",
|
||||
"full",
|
||||
"cross",
|
||||
"inner",
|
||||
"as",
|
||||
] {
|
||||
assert!(
|
||||
!cs.contains(&kw.to_string()),
|
||||
@@ -2108,10 +2140,7 @@ mod tests {
|
||||
// sort item the direction keywords surface as
|
||||
// continuations (previously discarded at the Repeated
|
||||
// boundary, so completion offered neither).
|
||||
let cache = schema_with_table(
|
||||
"Things",
|
||||
&[("Name", Type::Text), ("Qty", Type::Int)],
|
||||
);
|
||||
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
|
||||
let input = "select Name from Things order by Name ";
|
||||
let cs = cands_with(input, input.len(), &cache);
|
||||
assert!(cs.contains(&"asc".to_string()), "got {cs:?}");
|
||||
@@ -2123,10 +2152,7 @@ mod tests {
|
||||
use crate::dsl::types::Type;
|
||||
// walk_repeated trailing-optional fix: after a complete
|
||||
// projection item the `as` alias keyword surfaces.
|
||||
let cache = schema_with_table(
|
||||
"Things",
|
||||
&[("Name", Type::Text), ("Qty", Type::Int)],
|
||||
);
|
||||
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
|
||||
let input = "select Name ";
|
||||
let cs = cands_with(input, input.len(), &cache);
|
||||
assert!(cs.contains(&"as".to_string()), "got {cs:?}");
|
||||
@@ -2153,16 +2179,13 @@ mod tests {
|
||||
// ADR-0022 Amendment 2: at an expression position offering
|
||||
// both column names and keywords, every column precedes
|
||||
// every keyword so the names stay visible by default.
|
||||
let cache = schema_with_table(
|
||||
"Things",
|
||||
&[("Name", Type::Text), ("Qty", Type::Int)],
|
||||
);
|
||||
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
|
||||
let input = "select * from Things where ";
|
||||
let cs = cands_with(input, input.len(), &cache);
|
||||
let pos = |needle: &str| {
|
||||
cs.iter().position(|c| c == needle).unwrap_or_else(|| {
|
||||
panic!("{needle:?} not in candidates: {cs:?}")
|
||||
})
|
||||
cs.iter()
|
||||
.position(|c| c == needle)
|
||||
.unwrap_or_else(|| panic!("{needle:?} not in candidates: {cs:?}"))
|
||||
};
|
||||
// Both columns come before any expression-start keyword.
|
||||
let last_ident = pos("Name").max(pos("Qty"));
|
||||
@@ -2176,13 +2199,9 @@ mod tests {
|
||||
#[test]
|
||||
fn update_where_offers_only_current_table_columns() {
|
||||
use crate::dsl::types::Type;
|
||||
let mut cache = schema_with_table(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Email", Type::Text)],
|
||||
);
|
||||
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
|
||||
cache.columns.push("OrderTotal".to_string());
|
||||
let cs =
|
||||
cands_with("update Customers set Email='x' where ", 37, &cache);
|
||||
let cs = cands_with("update Customers set Email='x' where ", 37, &cache);
|
||||
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
|
||||
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
|
||||
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
|
||||
@@ -2208,7 +2227,11 @@ mod tests {
|
||||
use crate::dsl::types::Type;
|
||||
let cache = schema_with_table(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Email", Type::Text), ("Name", Type::Text)],
|
||||
&[
|
||||
("id", Type::Int),
|
||||
("Email", Type::Text),
|
||||
("Name", Type::Text),
|
||||
],
|
||||
);
|
||||
let cs = cands_with("insert into Customers (", 23, &cache);
|
||||
// The user is at Form A's column-list position. All
|
||||
@@ -2222,10 +2245,7 @@ mod tests {
|
||||
#[test]
|
||||
fn insert_into_open_paren_does_not_offer_unrelated_columns() {
|
||||
use crate::dsl::types::Type;
|
||||
let mut cache = schema_with_table(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Email", Type::Text)],
|
||||
);
|
||||
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
|
||||
cache.columns.push("OrderTotal".to_string());
|
||||
let cs = cands_with("insert into Customers (", 23, &cache);
|
||||
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
|
||||
@@ -2239,13 +2259,9 @@ mod tests {
|
||||
// table's columns. `OrderTotal` belongs to no table in
|
||||
// this cache's `table_columns`, so it must not leak.
|
||||
use crate::dsl::types::Type;
|
||||
let mut cache = schema_with_table(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Email", Type::Text)],
|
||||
);
|
||||
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
|
||||
cache.columns.push("OrderTotal".to_string());
|
||||
let cs =
|
||||
cands_with("drop column from Customers: ", 28, &cache);
|
||||
let cs = cands_with("drop column from Customers: ", 28, &cache);
|
||||
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
|
||||
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
|
||||
assert!(
|
||||
@@ -2271,8 +2287,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cursor_mid_keyword_replaces_only_the_partial_prefix() {
|
||||
let comp = candidates_at_cursor("cre", 3, &SchemaCache::default())
|
||||
.expect("some completion");
|
||||
let comp =
|
||||
candidates_at_cursor("cre", 3, &SchemaCache::default()).expect("some completion");
|
||||
assert_eq!(comp.replaced_range, (0, 3));
|
||||
assert_eq!(comp.partial_prefix, "cre");
|
||||
assert_eq!(comp.candidates.len(), 1);
|
||||
@@ -2282,8 +2298,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cursor_at_word_boundary_has_empty_partial_prefix() {
|
||||
let comp = candidates_at_cursor("create ", 7, &SchemaCache::default())
|
||||
.expect("some completion");
|
||||
let comp =
|
||||
candidates_at_cursor("create ", 7, &SchemaCache::default()).expect("some completion");
|
||||
assert_eq!(comp.replaced_range, (7, 7));
|
||||
assert_eq!(comp.partial_prefix, "");
|
||||
}
|
||||
@@ -2517,8 +2533,8 @@ mod tests {
|
||||
// inside `Name`, and substituting any name there
|
||||
// produces a complete command. No useful "next after
|
||||
// name" hint.
|
||||
let t = typing_name_at_cursor("add column to table T: Name (text)", 27)
|
||||
.expect("should fire");
|
||||
let t =
|
||||
typing_name_at_cursor("add column to table T: Name (text)", 27).expect("should fire");
|
||||
assert_eq!(t.next_after_name, None);
|
||||
}
|
||||
|
||||
@@ -2534,8 +2550,8 @@ mod tests {
|
||||
assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none());
|
||||
// `show data Cust` plus a typo: `show data Custp`. No
|
||||
// table starts with "Custp" → invalid.
|
||||
let invalid = invalid_ident_at_cursor("show data Custp", 15, &cache)
|
||||
.expect("should be invalid");
|
||||
let invalid =
|
||||
invalid_ident_at_cursor("show data Custp", 15, &cache).expect("should be invalid");
|
||||
assert_eq!(invalid.range, (10, 15));
|
||||
assert_eq!(invalid.found, "Custp");
|
||||
assert_eq!(invalid.source, IdentSource::Tables);
|
||||
@@ -2600,7 +2616,11 @@ mod tests {
|
||||
!cs.iter().any(|c| c == "Existing" || c == "AlsoExisting"),
|
||||
"NewName slot must not surface schema candidates; got {cs:?}"
|
||||
);
|
||||
assert_eq!(cs, vec!["if".to_string()], "only the advanced IF NOT EXISTS keyword");
|
||||
assert_eq!(
|
||||
cs,
|
||||
vec!["if".to_string()],
|
||||
"only the advanced IF NOT EXISTS keyword"
|
||||
);
|
||||
}
|
||||
|
||||
fn keyword_cand(text: &str) -> Candidate {
|
||||
@@ -2791,8 +2811,10 @@ mod tests {
|
||||
let cands = candidates_at_cursor(input, input.len(), &cache)
|
||||
.expect("some completion")
|
||||
.candidates;
|
||||
let count_entries: Vec<_> =
|
||||
cands.iter().filter(|c| c.text.eq_ignore_ascii_case("count")).collect();
|
||||
let count_entries: Vec<_> = cands
|
||||
.iter()
|
||||
.filter(|c| c.text.eq_ignore_ascii_case("count"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
count_entries.len(),
|
||||
1,
|
||||
@@ -2805,7 +2827,9 @@ mod tests {
|
||||
);
|
||||
// A non-colliding function at the same slot is unaffected.
|
||||
assert!(
|
||||
cands.iter().any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function),
|
||||
cands
|
||||
.iter()
|
||||
.any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function),
|
||||
"non-colliding functions still surface; got {cands:?}",
|
||||
);
|
||||
}
|
||||
@@ -2875,8 +2899,10 @@ mod tests {
|
||||
let mut s = SchemaCache::default();
|
||||
s.tables.push("OrderLines".into());
|
||||
s.columns.push("count".into());
|
||||
s.table_columns
|
||||
.insert("OrderLines".into(), vec![TableColumn::new("count", Type::Int)]);
|
||||
s.table_columns.insert(
|
||||
"OrderLines".into(),
|
||||
vec![TableColumn::new("count", Type::Int)],
|
||||
);
|
||||
let input = "select sum(ol.count) from OrderLines ol";
|
||||
let cursor = input.find("ol.count").unwrap() + 2; // right after `ol`
|
||||
assert!(
|
||||
@@ -2938,15 +2964,35 @@ mod tests {
|
||||
s.table_columns.insert(
|
||||
"a".to_string(),
|
||||
vec![
|
||||
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
|
||||
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
|
||||
TableColumn {
|
||||
name: "id".to_string(),
|
||||
user_type: Type::Int,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
},
|
||||
TableColumn {
|
||||
name: "name".to_string(),
|
||||
user_type: Type::Text,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
s.table_columns.insert(
|
||||
"b".to_string(),
|
||||
vec![
|
||||
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
|
||||
TableColumn { name: "total".to_string(), user_type: Type::Real, not_null: false, has_default: false },
|
||||
TableColumn {
|
||||
name: "id".to_string(),
|
||||
user_type: Type::Int,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
},
|
||||
TableColumn {
|
||||
name: "total".to_string(),
|
||||
user_type: Type::Real,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
s
|
||||
@@ -3191,5 +3237,3 @@ mod tests {
|
||||
assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+15
-11
@@ -549,14 +549,16 @@ pub enum AppCommand {
|
||||
/// word like `insert` / `create` / `show`, or `types`), the
|
||||
/// focused detail for that command (or command group sharing
|
||||
/// the entry word).
|
||||
Help {
|
||||
topic: Option<String>,
|
||||
},
|
||||
Help { topic: Option<String> },
|
||||
/// Show a contextual tier-3 hint (H2 / ADR-0053). No argument:
|
||||
/// when submitted, it expands on the most recent runtime error
|
||||
/// (the buffer is empty post-submit). The live-input surface is
|
||||
/// the F1 keybinding, handled in `App::handle_key`, not here.
|
||||
Hint,
|
||||
/// Print the application version (ADR-0054): the in-app twin of the
|
||||
/// `--version` / `-V` CLI flag. Emits `CARGO_PKG_VERSION` — the same
|
||||
/// single source of truth — into the output panel.
|
||||
Version,
|
||||
/// Rebuild `playground.db` from `project.yaml` + data/, with
|
||||
/// confirmation modal.
|
||||
Rebuild,
|
||||
@@ -576,7 +578,10 @@ pub enum AppCommand {
|
||||
/// Unpack a zip into a new project and switch to it.
|
||||
/// `target` overrides the project name (default: taken from
|
||||
/// the zip).
|
||||
Import { path: String, target: Option<String> },
|
||||
Import {
|
||||
path: String,
|
||||
target: Option<String>,
|
||||
},
|
||||
/// Switch the persistent input mode.
|
||||
Mode { value: ModeValue },
|
||||
/// Show or set the messages verbosity.
|
||||
@@ -787,9 +792,7 @@ impl PartialEq for Operand {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::Column { name: a, .. }, Self::Column { name: b, .. }) => a == b,
|
||||
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => {
|
||||
a == b
|
||||
}
|
||||
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -813,7 +816,9 @@ pub enum CompareOp {
|
||||
/// a single row in the metadata table.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RelationshipSelector {
|
||||
Named { name: String },
|
||||
Named {
|
||||
name: String,
|
||||
},
|
||||
Endpoints {
|
||||
parent_table: String,
|
||||
parent_column: String,
|
||||
@@ -1019,6 +1024,7 @@ impl Command {
|
||||
AppCommand::Quit => "quit",
|
||||
AppCommand::Help { .. } => "help",
|
||||
AppCommand::Hint => "hint",
|
||||
AppCommand::Version => "version",
|
||||
AppCommand::Rebuild => "rebuild",
|
||||
AppCommand::Save => "save",
|
||||
AppCommand::SaveAs => "save as",
|
||||
@@ -1151,9 +1157,7 @@ impl Command {
|
||||
parent_column,
|
||||
child_table,
|
||||
child_column,
|
||||
} => format!(
|
||||
"from {parent_table}.{parent_column} to {child_table}.{child_column}"
|
||||
),
|
||||
} => format!("from {parent_table}.{parent_column} to {child_table}.{child_column}"),
|
||||
},
|
||||
// A constraint command's subject is the dotted
|
||||
// `<table>.<column>` it acts on (ADR-0029 §2.2).
|
||||
|
||||
+46
-23
@@ -9,8 +9,7 @@
|
||||
|
||||
use crate::dsl::command::{AppCommand, Command, CopyScope, MessagesValue, ModeValue};
|
||||
use crate::dsl::grammar::{
|
||||
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError,
|
||||
Word,
|
||||
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError, Word,
|
||||
};
|
||||
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
|
||||
|
||||
@@ -60,19 +59,16 @@ const IMPORT_TARGET_IDENT: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
const IMPORT_TARGET: Node = Node::Hinted {
|
||||
mode: HintMode::ForceProse("hint.ambient_typing_name"),
|
||||
inner: &IMPORT_TARGET_IDENT,
|
||||
};
|
||||
|
||||
const IMPORT_AS_TARGET: Node = Node::Seq(&[
|
||||
Node::Word(Word::keyword("as")),
|
||||
IMPORT_TARGET,
|
||||
]);
|
||||
const IMPORT_AS_TARGET: Node = Node::Seq(&[Node::Word(Word::keyword("as")), IMPORT_TARGET]);
|
||||
const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_TARGET);
|
||||
|
||||
const IMPORT_PATH_AND_TARGET: Node = Node::Seq(&[Node::BarePath, IMPORT_AS_TARGET_OPT]);
|
||||
@@ -174,6 +170,10 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, Va
|
||||
Ok(Command::App(AppCommand::Rebuild))
|
||||
}
|
||||
|
||||
const fn build_version(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Version))
|
||||
}
|
||||
|
||||
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Undo))
|
||||
}
|
||||
@@ -267,7 +267,8 @@ pub static QUIT: CommandNode = CommandNode {
|
||||
ast_builder: build_quit,
|
||||
help_id: Some("app.quit"),
|
||||
hint_ids: &["quit"],
|
||||
usage_ids: &["parse.usage.quit"],};
|
||||
usage_ids: &["parse.usage.quit"],
|
||||
};
|
||||
|
||||
pub static HELP: CommandNode = CommandNode {
|
||||
entry: Word::keyword("help"),
|
||||
@@ -275,7 +276,8 @@ pub static HELP: CommandNode = CommandNode {
|
||||
ast_builder: build_help,
|
||||
help_id: Some("app.help"),
|
||||
hint_ids: &["help"],
|
||||
usage_ids: &["parse.usage.help"],};
|
||||
usage_ids: &["parse.usage.help"],
|
||||
};
|
||||
|
||||
pub static HINT: CommandNode = CommandNode {
|
||||
entry: Word::keyword("hint"),
|
||||
@@ -284,7 +286,8 @@ pub static HINT: CommandNode = CommandNode {
|
||||
help_id: Some("app.hint"),
|
||||
// hint_id assigned in Phase C with the tier-3 corpus (ADR-0053).
|
||||
hint_ids: &["hint"],
|
||||
usage_ids: &["parse.usage.hint"],};
|
||||
usage_ids: &["parse.usage.hint"],
|
||||
};
|
||||
|
||||
pub static REBUILD: CommandNode = CommandNode {
|
||||
entry: Word::keyword("rebuild"),
|
||||
@@ -292,7 +295,17 @@ pub static REBUILD: CommandNode = CommandNode {
|
||||
ast_builder: build_rebuild,
|
||||
help_id: Some("app.rebuild"),
|
||||
hint_ids: &["rebuild"],
|
||||
usage_ids: &["parse.usage.rebuild"],};
|
||||
usage_ids: &["parse.usage.rebuild"],
|
||||
};
|
||||
|
||||
pub static VERSION: CommandNode = CommandNode {
|
||||
entry: Word::keyword("version"),
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_version,
|
||||
help_id: Some("app.version"),
|
||||
hint_ids: &["version"],
|
||||
usage_ids: &["parse.usage.version"],
|
||||
};
|
||||
|
||||
pub static SAVE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("save"),
|
||||
@@ -300,7 +313,8 @@ pub static SAVE: CommandNode = CommandNode {
|
||||
ast_builder: build_save,
|
||||
help_id: Some("app.save"),
|
||||
hint_ids: &["save"],
|
||||
usage_ids: &["parse.usage.save"],};
|
||||
usage_ids: &["parse.usage.save"],
|
||||
};
|
||||
|
||||
pub static NEW: CommandNode = CommandNode {
|
||||
entry: Word::keyword("new"),
|
||||
@@ -308,7 +322,8 @@ pub static NEW: CommandNode = CommandNode {
|
||||
ast_builder: build_new,
|
||||
help_id: Some("app.new"),
|
||||
hint_ids: &["new"],
|
||||
usage_ids: &["parse.usage.new"],};
|
||||
usage_ids: &["parse.usage.new"],
|
||||
};
|
||||
|
||||
pub static LOAD: CommandNode = CommandNode {
|
||||
entry: Word::keyword("load"),
|
||||
@@ -316,7 +331,8 @@ pub static LOAD: CommandNode = CommandNode {
|
||||
ast_builder: build_load,
|
||||
help_id: Some("app.load"),
|
||||
hint_ids: &["load"],
|
||||
usage_ids: &["parse.usage.load"],};
|
||||
usage_ids: &["parse.usage.load"],
|
||||
};
|
||||
|
||||
pub static EXPORT: CommandNode = CommandNode {
|
||||
entry: Word::keyword("export"),
|
||||
@@ -324,7 +340,8 @@ pub static EXPORT: CommandNode = CommandNode {
|
||||
ast_builder: build_export,
|
||||
help_id: Some("app.export"),
|
||||
hint_ids: &["export"],
|
||||
usage_ids: &["parse.usage.export"],};
|
||||
usage_ids: &["parse.usage.export"],
|
||||
};
|
||||
|
||||
pub static IMPORT: CommandNode = CommandNode {
|
||||
entry: Word::keyword("import"),
|
||||
@@ -332,7 +349,8 @@ pub static IMPORT: CommandNode = CommandNode {
|
||||
ast_builder: build_import,
|
||||
help_id: Some("app.import"),
|
||||
hint_ids: &["import"],
|
||||
usage_ids: &["parse.usage.import"],};
|
||||
usage_ids: &["parse.usage.import"],
|
||||
};
|
||||
|
||||
pub static MODE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("mode"),
|
||||
@@ -340,7 +358,8 @@ pub static MODE: CommandNode = CommandNode {
|
||||
ast_builder: build_mode,
|
||||
help_id: Some("app.mode"),
|
||||
hint_ids: &["mode"],
|
||||
usage_ids: &["parse.usage.mode"],};
|
||||
usage_ids: &["parse.usage.mode"],
|
||||
};
|
||||
|
||||
pub static MESSAGES: CommandNode = CommandNode {
|
||||
entry: Word::keyword("messages"),
|
||||
@@ -348,7 +367,8 @@ pub static MESSAGES: CommandNode = CommandNode {
|
||||
ast_builder: build_messages,
|
||||
help_id: Some("app.messages"),
|
||||
hint_ids: &["messages"],
|
||||
usage_ids: &["parse.usage.messages"],};
|
||||
usage_ids: &["parse.usage.messages"],
|
||||
};
|
||||
|
||||
pub static UNDO: CommandNode = CommandNode {
|
||||
entry: Word::keyword("undo"),
|
||||
@@ -356,7 +376,8 @@ pub static UNDO: CommandNode = CommandNode {
|
||||
ast_builder: build_undo,
|
||||
help_id: Some("app.undo"),
|
||||
hint_ids: &["undo"],
|
||||
usage_ids: &["parse.usage.undo"],};
|
||||
usage_ids: &["parse.usage.undo"],
|
||||
};
|
||||
|
||||
pub static REDO: CommandNode = CommandNode {
|
||||
entry: Word::keyword("redo"),
|
||||
@@ -364,7 +385,8 @@ pub static REDO: CommandNode = CommandNode {
|
||||
ast_builder: build_redo,
|
||||
help_id: Some("app.redo"),
|
||||
hint_ids: &["redo"],
|
||||
usage_ids: &["parse.usage.redo"],};
|
||||
usage_ids: &["parse.usage.redo"],
|
||||
};
|
||||
|
||||
pub static COPY: CommandNode = CommandNode {
|
||||
entry: Word::keyword("copy"),
|
||||
@@ -372,4 +394,5 @@ pub static COPY: CommandNode = CommandNode {
|
||||
ast_builder: build_copy,
|
||||
help_id: Some("app.copy"),
|
||||
hint_ids: &["copy"],
|
||||
usage_ids: &["parse.usage.copy"],};
|
||||
usage_ids: &["parse.usage.copy"],
|
||||
};
|
||||
|
||||
+97
-74
@@ -24,19 +24,17 @@
|
||||
//! later swap that capture for the same typed slots used here, adding
|
||||
//! live hints/highlighting.
|
||||
|
||||
use crate::dsl::command::{
|
||||
Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind,
|
||||
};
|
||||
use crate::dsl::command::{Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind};
|
||||
use crate::dsl::grammar::{
|
||||
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
|
||||
shared::{
|
||||
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values,
|
||||
current_column_value, insert_target_columns,
|
||||
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values, current_column_value,
|
||||
insert_target_columns,
|
||||
},
|
||||
sql_delete, sql_insert, sql_select, sql_update,
|
||||
};
|
||||
use crate::dsl::walker::context::WalkContext;
|
||||
use crate::dsl::value::Value;
|
||||
use crate::dsl::walker::context::WalkContext;
|
||||
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
|
||||
|
||||
// =================================================================
|
||||
@@ -57,9 +55,9 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
/// Table-name slot variant that populates
|
||||
@@ -76,9 +74,9 @@ const TABLE_NAME_INSERT: Node = Node::Ident {
|
||||
writes_table: true,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
@@ -95,10 +93,7 @@ const SHOW_DATA_NODES: &[Node] = &[
|
||||
];
|
||||
const SHOW_DATA: Node = Node::Seq(SHOW_DATA_NODES);
|
||||
|
||||
const SHOW_TABLE_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("table")),
|
||||
TABLE_NAME_EXISTING,
|
||||
];
|
||||
const SHOW_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
|
||||
const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES);
|
||||
|
||||
// `show tables` / `show relationships` / `show indexes` — the
|
||||
@@ -144,8 +139,7 @@ const SHOW_INDEX_NAME: Node = Node::Ident {
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
const SHOW_INDEX_NODES: &[Node] =
|
||||
&[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
|
||||
const SHOW_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
|
||||
const SHOW_INDEX: Node = Node::Seq(SHOW_INDEX_NODES);
|
||||
|
||||
const SHOW_CHOICES: &[Node] = &[
|
||||
@@ -192,9 +186,9 @@ static FORM_A_COLUMN: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: true,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
static INSERT_COMMA: Node = Node::Punct(',');
|
||||
|
||||
@@ -224,8 +218,7 @@ fn insert_first_paren(ctx: &WalkContext, source: &str, pos: usize) -> Node {
|
||||
/// or an identifier-shaped token (a column name) returns false.
|
||||
fn first_paren_item_is_value_literal(source: &str, pos: usize) -> bool {
|
||||
use crate::dsl::walker::lex_helpers::{
|
||||
consume_ident, consume_number_literal, consume_string_literal,
|
||||
skip_whitespace,
|
||||
consume_ident, consume_number_literal, consume_string_literal, skip_whitespace,
|
||||
};
|
||||
let p = skip_whitespace(source, pos);
|
||||
if p >= source.len() {
|
||||
@@ -281,7 +274,11 @@ fn dsl_insert_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
|
||||
return FALLBACK_VALUE_LIST;
|
||||
};
|
||||
let (count, closed) = count_tuple_values(source, pos);
|
||||
let arity_ok = if closed { count == cols.len() } else { count <= cols.len() };
|
||||
let arity_ok = if closed {
|
||||
count == cols.len()
|
||||
} else {
|
||||
count <= cols.len()
|
||||
};
|
||||
if arity_ok {
|
||||
Node::DynamicSubgrammar(column_value_list)
|
||||
} else {
|
||||
@@ -320,8 +317,7 @@ const INSERT_VALUES_KEYWORD_FIRST_NODES: &[Node] = &[
|
||||
];
|
||||
const INSERT_VALUES_KEYWORD_FIRST: Node = Node::Seq(INSERT_VALUES_KEYWORD_FIRST_NODES);
|
||||
|
||||
const INSERT_AFTER_TABLE_CHOICES: &[Node] =
|
||||
&[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
|
||||
const INSERT_AFTER_TABLE_CHOICES: &[Node] = &[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
|
||||
const INSERT_AFTER_TABLE: Node = Node::Choice(INSERT_AFTER_TABLE_CHOICES);
|
||||
|
||||
const INSERT_NODES: &[Node] = &[
|
||||
@@ -350,9 +346,9 @@ const TABLE_NAME_WRITES: Node = Node::Ident {
|
||||
writes_table: true,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
/// Column-name slot in `set col = …` — resolves the column's
|
||||
@@ -366,9 +362,9 @@ const SET_COLUMN: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: true,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
/// Value slot resolved at walk time from
|
||||
@@ -376,11 +372,7 @@ writes_projection_alias: false,
|
||||
/// value-literal choice when no current_column is bound.
|
||||
const PER_COLUMN_VALUE: Node = Node::DynamicSubgrammar(current_column_value);
|
||||
|
||||
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[
|
||||
SET_COLUMN,
|
||||
Node::Punct('='),
|
||||
PER_COLUMN_VALUE,
|
||||
];
|
||||
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[SET_COLUMN, Node::Punct('='), PER_COLUMN_VALUE];
|
||||
const UPDATE_ASSIGNMENT: Node = Node::Seq(UPDATE_ASSIGNMENT_NODES);
|
||||
const UPDATE_ASSIGNMENTS: Node = Node::Repeated {
|
||||
inner: &UPDATE_ASSIGNMENT,
|
||||
@@ -568,8 +560,7 @@ const SEED_OVERRIDES: Node = Node::Repeated {
|
||||
separator: Some(&Node::Punct(',')),
|
||||
min: 1,
|
||||
};
|
||||
const SEED_SET_CLAUSE_NODES: &[Node] =
|
||||
&[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
|
||||
const SEED_SET_CLAUSE_NODES: &[Node] = &[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
|
||||
const SEED_SET_CLAUSE: Node = Node::Seq(SEED_SET_CLAUSE_NODES);
|
||||
|
||||
const SEED_NODES: &[Node] = &[
|
||||
@@ -980,7 +971,10 @@ fn parse_seed_override_tail(
|
||||
MatchedKind::Word("in") => {
|
||||
*i += 1; // `in`
|
||||
// `(`
|
||||
if matches!(region.get(*i).map(|t| &t.kind), Some(MatchedKind::Punct('('))) {
|
||||
if matches!(
|
||||
region.get(*i).map(|t| &t.kind),
|
||||
Some(MatchedKind::Punct('('))
|
||||
) {
|
||||
*i += 1;
|
||||
}
|
||||
let mut values = Vec::new();
|
||||
@@ -1001,7 +995,10 @@ fn parse_seed_override_tail(
|
||||
MatchedKind::Word("between") => {
|
||||
*i += 1; // `between`
|
||||
let low = seed_take_value(region, i, column)?;
|
||||
if matches!(region.get(*i).map(|t| &t.kind), Some(MatchedKind::Word("and"))) {
|
||||
if matches!(
|
||||
region.get(*i).map(|t| &t.kind),
|
||||
Some(MatchedKind::Word("and"))
|
||||
) {
|
||||
*i += 1;
|
||||
}
|
||||
let high = seed_take_value(region, i, column)?;
|
||||
@@ -1011,7 +1008,15 @@ fn parse_seed_override_tail(
|
||||
*i += 1; // `as`
|
||||
let gen_item = region
|
||||
.get(*i)
|
||||
.filter(|t| matches!(t.kind, MatchedKind::Ident { role: "seed_generator", .. }))
|
||||
.filter(|t| {
|
||||
matches!(
|
||||
t.kind,
|
||||
MatchedKind::Ident {
|
||||
role: "seed_generator",
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| seed_set_error(column))?;
|
||||
*i += 1;
|
||||
Ok(SeedOverrideKind::Generator(gen_item.text.clone()))
|
||||
@@ -1085,7 +1090,15 @@ fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, Validation
|
||||
let table_idx = path
|
||||
.items
|
||||
.iter()
|
||||
.position(|i| matches!(&i.kind, MatchedKind::Ident { role: "table_name", .. }))
|
||||
.position(|i| {
|
||||
matches!(
|
||||
&i.kind,
|
||||
MatchedKind::Ident {
|
||||
role: "table_name",
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| ValidationError {
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "missing table".to_string())],
|
||||
@@ -1141,7 +1154,10 @@ fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, Validation
|
||||
if columns.is_empty() {
|
||||
return Err(ValidationError {
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "expected column names in `insert into T (…)`".to_string())],
|
||||
args: vec![(
|
||||
"detail",
|
||||
"expected column names in `insert into T (…)`".to_string(),
|
||||
)],
|
||||
});
|
||||
}
|
||||
// Find the `values` keyword and the next `(` — the values
|
||||
@@ -1247,9 +1263,7 @@ fn build_update(path: &MatchedPath, _source: &str) -> Result<Command, Validation
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_assignments(
|
||||
path: &MatchedPath,
|
||||
) -> Result<Vec<(String, Value)>, ValidationError> {
|
||||
fn collect_assignments(path: &MatchedPath) -> Result<Vec<(String, Value)>, ValidationError> {
|
||||
let mut out = Vec::new();
|
||||
let mut iter = path.items.iter();
|
||||
while let Some(item) = iter.next() {
|
||||
@@ -1495,9 +1509,7 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
|
||||
let row_source = path
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| {
|
||||
matches!(item.kind, MatchedKind::Word("values" | "select" | "with"))
|
||||
})
|
||||
.find(|item| matches!(item.kind, MatchedKind::Word("values" | "select" | "with")))
|
||||
.map(|item| {
|
||||
let end = tail_start.unwrap_or(source.len());
|
||||
source[item.span.0..end]
|
||||
@@ -1805,7 +1817,8 @@ pub static SHOW: CommandNode = CommandNode {
|
||||
"parse.usage.show_indexes",
|
||||
"parse.usage.show_relationship",
|
||||
"parse.usage.show_index",
|
||||
],};
|
||||
],
|
||||
};
|
||||
|
||||
pub static SEED: CommandNode = CommandNode {
|
||||
entry: Word::keyword("seed"),
|
||||
@@ -1823,7 +1836,8 @@ pub static INSERT: CommandNode = CommandNode {
|
||||
help_id: Some("data.insert"),
|
||||
// ADR-0053 Phase-B exemplar.
|
||||
hint_ids: &["insert"],
|
||||
usage_ids: &["parse.usage.insert"],};
|
||||
usage_ids: &["parse.usage.insert"],
|
||||
};
|
||||
|
||||
pub static UPDATE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("update"),
|
||||
@@ -1831,7 +1845,8 @@ pub static UPDATE: CommandNode = CommandNode {
|
||||
ast_builder: build_update,
|
||||
help_id: Some("data.update"),
|
||||
hint_ids: &["update"],
|
||||
usage_ids: &["parse.usage.update"],};
|
||||
usage_ids: &["parse.usage.update"],
|
||||
};
|
||||
|
||||
pub static DELETE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("delete"),
|
||||
@@ -1839,7 +1854,8 @@ pub static DELETE: CommandNode = CommandNode {
|
||||
ast_builder: build_delete,
|
||||
help_id: Some("data.delete"),
|
||||
hint_ids: &["delete"],
|
||||
usage_ids: &["parse.usage.delete"],};
|
||||
usage_ids: &["parse.usage.delete"],
|
||||
};
|
||||
|
||||
pub static REPLAY: CommandNode = CommandNode {
|
||||
entry: Word::keyword("replay"),
|
||||
@@ -1847,7 +1863,8 @@ pub static REPLAY: CommandNode = CommandNode {
|
||||
ast_builder: build_replay,
|
||||
help_id: Some("data.replay"),
|
||||
hint_ids: &["replay"],
|
||||
usage_ids: &["parse.usage.replay"],};
|
||||
usage_ids: &["parse.usage.replay"],
|
||||
};
|
||||
|
||||
pub static EXPLAIN: CommandNode = CommandNode {
|
||||
entry: Word::keyword("explain"),
|
||||
@@ -1855,7 +1872,8 @@ pub static EXPLAIN: CommandNode = CommandNode {
|
||||
ast_builder: build_explain,
|
||||
help_id: Some("data.explain"),
|
||||
hint_ids: &["explain"],
|
||||
usage_ids: &["parse.usage.explain"],};
|
||||
usage_ids: &["parse.usage.explain"],
|
||||
};
|
||||
|
||||
/// `explain` over advanced-mode SQL (ADR-0039).
|
||||
///
|
||||
@@ -1875,7 +1893,8 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
|
||||
// precedent; otherwise `note_help` would print `explain` twice.
|
||||
help_id: None,
|
||||
hint_ids: &["explain_sql"],
|
||||
usage_ids: &[],};
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
|
||||
///
|
||||
@@ -1891,7 +1910,8 @@ pub static SELECT: CommandNode = CommandNode {
|
||||
ast_builder: build_select,
|
||||
help_id: None,
|
||||
hint_ids: &["select"],
|
||||
usage_ids: &["parse.usage.select"],};
|
||||
usage_ids: &["parse.usage.select"],
|
||||
};
|
||||
|
||||
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
|
||||
///
|
||||
@@ -1906,7 +1926,8 @@ pub static WITH: CommandNode = CommandNode {
|
||||
ast_builder: build_select,
|
||||
help_id: None,
|
||||
hint_ids: &["with"],
|
||||
usage_ids: &["parse.usage.with"],};
|
||||
usage_ids: &["parse.usage.with"],
|
||||
};
|
||||
|
||||
/// SQL `INSERT` — the `Advanced`-category node of the shared
|
||||
/// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j).
|
||||
@@ -1993,7 +2014,11 @@ mod explain_tests {
|
||||
#[test]
|
||||
fn explain_show_data_carries_where_and_limit_through() {
|
||||
match explain_inner("explain show data Customers where id = 1 limit 5") {
|
||||
Command::ShowData { name, filter, limit } => {
|
||||
Command::ShowData {
|
||||
name,
|
||||
filter,
|
||||
limit,
|
||||
} => {
|
||||
assert_eq!(name, "Customers");
|
||||
assert!(filter.is_some(), "where clause should survive");
|
||||
assert_eq!(limit, Some(5));
|
||||
@@ -2052,9 +2077,7 @@ mod explain_tests {
|
||||
|
||||
/// Advanced-mode counterpart of `explain_inner`.
|
||||
fn explain_inner_adv(input: &str) -> Command {
|
||||
match parse_command_in_mode(input, Mode::Advanced)
|
||||
.expect("advanced explain should parse")
|
||||
{
|
||||
match parse_command_in_mode(input, Mode::Advanced).expect("advanced explain should parse") {
|
||||
Command::Explain { query } => *query,
|
||||
other => panic!("expected Command::Explain, got {other:?}"),
|
||||
}
|
||||
@@ -2085,7 +2108,9 @@ mod explain_tests {
|
||||
#[test]
|
||||
fn explain_sql_insert_wraps_a_sql_insert() {
|
||||
match explain_inner_adv("explain insert into Customers values (1, 'Bo')") {
|
||||
Command::SqlInsert { sql, target_table, .. } => {
|
||||
Command::SqlInsert {
|
||||
sql, target_table, ..
|
||||
} => {
|
||||
assert_eq!(target_table, "Customers");
|
||||
assert_eq!(sql, "insert into Customers values (1, 'Bo')");
|
||||
}
|
||||
@@ -2096,7 +2121,9 @@ mod explain_tests {
|
||||
#[test]
|
||||
fn explain_sql_update_wraps_a_sql_update_with_clean_sql() {
|
||||
match explain_inner_adv("explain update Customers set Name = 'Bo' where id = 1") {
|
||||
Command::SqlUpdate { sql, target_table, .. } => {
|
||||
Command::SqlUpdate {
|
||||
sql, target_table, ..
|
||||
} => {
|
||||
assert_eq!(target_table, "Customers");
|
||||
assert_eq!(sql, "update Customers set Name = 'Bo' where id = 1");
|
||||
}
|
||||
@@ -2107,7 +2134,9 @@ mod explain_tests {
|
||||
#[test]
|
||||
fn explain_sql_delete_wraps_a_sql_delete() {
|
||||
match explain_inner_adv("explain delete from Customers where id = 1") {
|
||||
Command::SqlDelete { sql, target_table, .. } => {
|
||||
Command::SqlDelete {
|
||||
sql, target_table, ..
|
||||
} => {
|
||||
assert_eq!(target_table, "Customers");
|
||||
assert_eq!(sql, "delete from Customers where id = 1");
|
||||
}
|
||||
@@ -2148,11 +2177,7 @@ mod explain_tests {
|
||||
fn explain_does_not_cover_ddl() {
|
||||
// EXPLAIN QUERY PLAN applies to DML/queries only (ADR-0039
|
||||
// out of scope); there is no SQL DDL branch under explain.
|
||||
assert!(parse_command_in_mode(
|
||||
"explain create table T (id int)",
|
||||
Mode::Advanced,
|
||||
)
|
||||
.is_err());
|
||||
assert!(parse_command_in_mode("explain create table T (id int)", Mode::Advanced,).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2165,8 +2190,7 @@ mod explain_tests {
|
||||
use crate::completion::candidates_at_cursor_in_mode;
|
||||
let schema = crate::completion::SchemaCache::default();
|
||||
let input = "explain ";
|
||||
let completion =
|
||||
candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
|
||||
let completion = candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
|
||||
.expect("explain offers candidates");
|
||||
let names: Vec<&str> = completion
|
||||
.candidates
|
||||
@@ -2178,4 +2202,3 @@ mod explain_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+210
-130
@@ -16,11 +16,11 @@ use crate::dsl::command::{
|
||||
AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
|
||||
IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint,
|
||||
};
|
||||
use crate::dsl::value::Value;
|
||||
use crate::dsl::grammar::{
|
||||
CommandNode, HighlightClass, HintMode, IdentSource, Node, ValidationError, Word,
|
||||
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
|
||||
};
|
||||
use crate::dsl::value::Value;
|
||||
|
||||
/// `HintMode` annotation shared by every `NewName` ident slot:
|
||||
/// the user is inventing a name, so the hint panel forces the
|
||||
@@ -42,9 +42,9 @@ const TABLE_NAME_NEW_IDENT: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
const TABLE_NAME_NEW: Node = Node::Hinted {
|
||||
mode: NEW_NAME_HINT,
|
||||
@@ -66,9 +66,9 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
|
||||
writes_table: true,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
const COLUMN_NAME: Node = Node::Ident {
|
||||
@@ -79,9 +79,9 @@ const COLUMN_NAME: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
|
||||
@@ -92,9 +92,9 @@ const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
const COLUMN_NAME_NEW: Node = Node::Hinted {
|
||||
mode: NEW_NAME_HINT,
|
||||
@@ -109,9 +109,9 @@ const RELATIONSHIP_NAME: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
|
||||
@@ -122,9 +122,9 @@ const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
|
||||
mode: NEW_NAME_HINT,
|
||||
@@ -139,9 +139,9 @@ const INDEX_NAME_EXISTING: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
|
||||
@@ -152,9 +152,9 @@ const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
const INDEX_NAME_NEW: Node = Node::Hinted {
|
||||
mode: NEW_NAME_HINT,
|
||||
@@ -181,10 +181,7 @@ const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table")));
|
||||
// drop_table — `drop table <T>`
|
||||
// =================================================================
|
||||
|
||||
const DROP_TABLE_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("table")),
|
||||
TABLE_NAME_EXISTING,
|
||||
];
|
||||
const DROP_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
|
||||
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
|
||||
|
||||
// Advanced-mode SQL `DROP TABLE [IF EXISTS] <name> [;]` (ADR-0035 §4,
|
||||
@@ -192,8 +189,10 @@ const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
|
||||
// plus the optional `IF EXISTS` no-op-with-note. The leading concrete
|
||||
// `table` keyword (not the Optional) keeps the element/dispatch
|
||||
// matching honest.
|
||||
static SQL_DROP_IF_EXISTS_NODES: &[Node] =
|
||||
&[Node::Word(Word::keyword("if")), Node::Word(Word::keyword("exists"))];
|
||||
static SQL_DROP_IF_EXISTS_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("if")),
|
||||
Node::Word(Word::keyword("exists")),
|
||||
];
|
||||
const SQL_DROP_IF_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_DROP_IF_EXISTS_NODES));
|
||||
static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("table")),
|
||||
@@ -317,10 +316,7 @@ const DR_ENDPOINTS: Node = Node::Seq(DR_ENDPOINTS_NODES);
|
||||
const DR_SELECTOR_CHOICES: &[Node] = &[DR_ENDPOINTS, RELATIONSHIP_NAME];
|
||||
const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES);
|
||||
|
||||
const DROP_RELATIONSHIP_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("relationship")),
|
||||
DR_SELECTOR,
|
||||
];
|
||||
const DROP_RELATIONSHIP_NODES: &[Node] = &[Node::Word(Word::keyword("relationship")), DR_SELECTOR];
|
||||
const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES);
|
||||
|
||||
// =================================================================
|
||||
@@ -341,18 +337,20 @@ const DI_POSITIONAL: Node = Node::Seq(DI_POSITIONAL_NODES);
|
||||
const DI_SELECTOR_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING];
|
||||
const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES);
|
||||
|
||||
const DROP_INDEX_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("index")),
|
||||
DI_SELECTOR,
|
||||
];
|
||||
const DROP_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), DI_SELECTOR];
|
||||
const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);
|
||||
|
||||
// =================================================================
|
||||
// drop entry — `drop (table|column|relationship|index) ...`
|
||||
// =================================================================
|
||||
|
||||
const DROP_CHOICES: &[Node] =
|
||||
&[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX, DROP_CONSTRAINT];
|
||||
const DROP_CHOICES: &[Node] = &[
|
||||
DROP_COLUMN,
|
||||
DROP_RELATIONSHIP,
|
||||
DROP_TABLE,
|
||||
DROP_INDEX,
|
||||
DROP_CONSTRAINT,
|
||||
];
|
||||
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
|
||||
|
||||
// =================================================================
|
||||
@@ -450,8 +448,7 @@ const AR_CHILD_COL_LIST: Node = Node::Repeated {
|
||||
separator: Some(&Node::Punct(',')),
|
||||
min: 1,
|
||||
};
|
||||
const AR_CHILD_COLS_PAREN_NODES: &[Node] =
|
||||
&[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
|
||||
const AR_CHILD_COLS_PAREN_NODES: &[Node] = &[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
|
||||
const AR_CHILD_COLS_PAREN: Node = Node::Seq(AR_CHILD_COLS_PAREN_NODES);
|
||||
const AR_CHILD_COLS_CHOICES: &[Node] = &[AR_CHILD_COLS_PAREN, AR_CHILD_COL];
|
||||
const AR_CHILD_COLS: Node = Node::Choice(AR_CHILD_COLS_CHOICES);
|
||||
@@ -474,10 +471,7 @@ const AR_CHILD_NODES: &[Node] = &[
|
||||
];
|
||||
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
|
||||
|
||||
const AR_AS_NAME_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("as")),
|
||||
RELATIONSHIP_NAME_NEW,
|
||||
];
|
||||
const AR_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), RELATIONSHIP_NAME_NEW];
|
||||
const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES));
|
||||
|
||||
const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk"));
|
||||
@@ -501,10 +495,7 @@ const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES);
|
||||
// add_index — `add index [as <name>] on <T> (<col>, …)`
|
||||
// =================================================================
|
||||
|
||||
const AI_AS_NAME_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("as")),
|
||||
INDEX_NAME_NEW,
|
||||
];
|
||||
const AI_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), INDEX_NAME_NEW];
|
||||
const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES));
|
||||
|
||||
const ADD_INDEX_NODES: &[Node] = &[
|
||||
@@ -537,9 +528,9 @@ const NEW_COLUMN_NAME_IDENT: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
const NEW_COLUMN_NAME: Node = Node::Hinted {
|
||||
mode: NEW_NAME_HINT,
|
||||
@@ -563,10 +554,7 @@ const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES);
|
||||
// ( <type> ) [--force-conversion | --dont-convert]`
|
||||
// =================================================================
|
||||
|
||||
const CHANGE_FLAG_CHOICES: &[Node] = &[
|
||||
Node::Flag("force-conversion"),
|
||||
Node::Flag("dont-convert"),
|
||||
];
|
||||
const CHANGE_FLAG_CHOICES: &[Node] = &[Node::Flag("force-conversion"), Node::Flag("dont-convert")];
|
||||
const CHANGE_FLAG_OPT: Node = Node::Repeated {
|
||||
inner: &Node::Choice(CHANGE_FLAG_CHOICES),
|
||||
separator: None,
|
||||
@@ -732,8 +720,7 @@ fn build_add(path: &MatchedPath, _source: &str) -> Result<Command, ValidationErr
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "unknown type".to_string())],
|
||||
})?;
|
||||
let (not_null, unique, default, check) =
|
||||
collect_column_constraints(path)?;
|
||||
let (not_null, unique, default, check) = collect_column_constraints(path)?;
|
||||
Ok(Command::AddColumn {
|
||||
table: require_ident(path, "table_name")?,
|
||||
column: require_ident(path, "column_name")?,
|
||||
@@ -949,7 +936,10 @@ fn build_drop_constraint(path: &MatchedPath, _source: &str) -> Result<Command, V
|
||||
} else {
|
||||
return Err(ValidationError {
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "drop constraint needs a constraint kind".to_string())],
|
||||
args: vec![(
|
||||
"detail",
|
||||
"drop constraint needs a constraint kind".to_string(),
|
||||
)],
|
||||
});
|
||||
};
|
||||
Ok(Command::DropConstraint {
|
||||
@@ -981,7 +971,8 @@ pub static DROP: CommandNode = CommandNode {
|
||||
"parse.usage.drop_relationship",
|
||||
"parse.usage.drop_index",
|
||||
"parse.usage.drop_constraint",
|
||||
],};
|
||||
],
|
||||
};
|
||||
|
||||
pub static ADD: CommandNode = CommandNode {
|
||||
entry: Word::keyword("add"),
|
||||
@@ -1003,7 +994,8 @@ pub static ADD: CommandNode = CommandNode {
|
||||
"parse.usage.add_relationship",
|
||||
"parse.usage.add_index",
|
||||
"parse.usage.add_constraint",
|
||||
],};
|
||||
],
|
||||
};
|
||||
|
||||
pub static RENAME: CommandNode = CommandNode {
|
||||
entry: Word::keyword("rename"),
|
||||
@@ -1011,7 +1003,8 @@ pub static RENAME: CommandNode = CommandNode {
|
||||
ast_builder: build_rename_column,
|
||||
help_id: Some("ddl.rename"),
|
||||
hint_ids: &["rename_column"],
|
||||
usage_ids: &["parse.usage.rename_column"],};
|
||||
usage_ids: &["parse.usage.rename_column"],
|
||||
};
|
||||
|
||||
pub static CHANGE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("change"),
|
||||
@@ -1019,7 +1012,8 @@ pub static CHANGE: CommandNode = CommandNode {
|
||||
ast_builder: build_change_column,
|
||||
help_id: Some("ddl.change"),
|
||||
hint_ids: &["change_column"],
|
||||
usage_ids: &["parse.usage.change_column"],};
|
||||
usage_ids: &["parse.usage.change_column"],
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// create_table — `create table <Name> [with pk [<col>(<type>)[, ...]]]`
|
||||
@@ -1034,9 +1028,9 @@ const COL_NAME_IDENT: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
const COL_NAME: Node = Node::Hinted {
|
||||
mode: NEW_NAME_HINT,
|
||||
@@ -1074,8 +1068,12 @@ const CHECK_CONSTRAINT_NODES: &[Node] = &[
|
||||
];
|
||||
const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES);
|
||||
|
||||
const COLUMN_CONSTRAINT_CHOICES: &[Node] =
|
||||
&[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT, CHECK_CONSTRAINT];
|
||||
const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[
|
||||
NOT_NULL_CONSTRAINT,
|
||||
UNIQUE_CONSTRAINT,
|
||||
DEFAULT_CONSTRAINT,
|
||||
CHECK_CONSTRAINT,
|
||||
];
|
||||
const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES);
|
||||
|
||||
/// Zero-or-more constraints — the suffix after a column's
|
||||
@@ -1114,8 +1112,7 @@ const DROP_CONSTRAINT_KIND: Node = Node::Choice(DROP_CONSTRAINT_KIND_CHOICES);
|
||||
// `writes_table: true` on the table ident (via `TABLE_NAME_
|
||||
// EXISTING`) narrows the `.<column>` slot's completion
|
||||
// candidates to that table's columns.
|
||||
const CONSTRAINT_TARGET_NODES: &[Node] =
|
||||
&[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
|
||||
const CONSTRAINT_TARGET_NODES: &[Node] = &[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
|
||||
const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES);
|
||||
|
||||
const ADD_CONSTRAINT_NODES: &[Node] = &[
|
||||
@@ -1275,10 +1272,14 @@ fn build_create_table(path: &MatchedPath, _source: &str) -> Result<Command, Vali
|
||||
let mut items = path.items.iter().peekable();
|
||||
while let Some(item) = items.next() {
|
||||
match &item.kind {
|
||||
MatchedKind::Ident { role: "col_name", .. } => {
|
||||
MatchedKind::Ident {
|
||||
role: "col_name", ..
|
||||
} => {
|
||||
pending_name = Some(item.text.clone());
|
||||
}
|
||||
MatchedKind::Ident { role: "col_type", .. } => {
|
||||
MatchedKind::Ident {
|
||||
role: "col_type", ..
|
||||
} => {
|
||||
let ty = item.text.parse::<Type>().map_err(|_| ValidationError {
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "unknown type".to_string())],
|
||||
@@ -1380,7 +1381,8 @@ pub static CREATE: CommandNode = CommandNode {
|
||||
ast_builder: build_create_table,
|
||||
help_id: Some("ddl.create"),
|
||||
hint_ids: &["create_table"],
|
||||
usage_ids: &["parse.usage.create_table"],};
|
||||
usage_ids: &["parse.usage.create_table"],
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// create_m2n — `create m:n relationship from <T1> to <T2> [as <name>]`
|
||||
@@ -1506,11 +1508,15 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
||||
while let Some(item) = items.next() {
|
||||
match &item.kind {
|
||||
// A column name stashes until its type finalises the spec.
|
||||
MatchedKind::Ident { role: "col_name", .. } => {
|
||||
MatchedKind::Ident {
|
||||
role: "col_name", ..
|
||||
} => {
|
||||
pending_name = Some(item.text.clone());
|
||||
}
|
||||
// Single-word type — resolve through the SQL alias map.
|
||||
MatchedKind::Ident { role: "col_type", .. } => {
|
||||
MatchedKind::Ident {
|
||||
role: "col_type", ..
|
||||
} => {
|
||||
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "unknown type".to_string())],
|
||||
@@ -1533,7 +1539,9 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
||||
column_open = true;
|
||||
}
|
||||
// A table-level `PRIMARY KEY (col, …)` column reference.
|
||||
MatchedKind::Ident { role: "pk_column", .. } => {
|
||||
MatchedKind::Ident {
|
||||
role: "pk_column", ..
|
||||
} => {
|
||||
primary_key.push(item.text.clone());
|
||||
}
|
||||
// `not null` column constraint (only once a column exists;
|
||||
@@ -1557,7 +1565,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
||||
let mut cols: Vec<String> = Vec::new();
|
||||
while let Some(it) = items.peek() {
|
||||
match &it.kind {
|
||||
MatchedKind::Ident { role: "unique_column", .. } => {
|
||||
MatchedKind::Ident {
|
||||
role: "unique_column",
|
||||
..
|
||||
} => {
|
||||
cols.push(it.text.clone());
|
||||
items.next();
|
||||
}
|
||||
@@ -1575,7 +1586,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
||||
// column's flag (round-trips via the single-column
|
||||
// path); composite (or a name not among the
|
||||
// columns) becomes a constraint.
|
||||
match columns.iter_mut().find(|c| cols.len() == 1 && c.name == cols[0]) {
|
||||
match columns
|
||||
.iter_mut()
|
||||
.find(|c| cols.len() == 1 && c.name == cols[0])
|
||||
{
|
||||
Some(c) => c.unique = true,
|
||||
None if !cols.is_empty() => unique_constraints.push(cols),
|
||||
None => {}
|
||||
@@ -1588,16 +1602,17 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
||||
// the most recent column) or the table-level clause (whose
|
||||
// `pk_column` idents follow and are collected above).
|
||||
MatchedKind::Word("primary") => {
|
||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
|
||||
if matches!(
|
||||
items.peek().map(|i| &i.kind),
|
||||
Some(MatchedKind::Word("key"))
|
||||
) {
|
||||
items.next();
|
||||
// Table-level `PRIMARY KEY (…)` is followed by `(`
|
||||
// (then `pk_column` idents, collected above);
|
||||
// column-level `PRIMARY KEY` is not, and marks the
|
||||
// most-recent column.
|
||||
let table_level = matches!(
|
||||
items.peek().map(|i| &i.kind),
|
||||
Some(MatchedKind::Punct('('))
|
||||
);
|
||||
let table_level =
|
||||
matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('(')));
|
||||
if !table_level && let Some(last) = columns.last() {
|
||||
primary_key.push(last.name.clone());
|
||||
}
|
||||
@@ -1647,12 +1662,20 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
||||
// Inline FK is single-column (the column it sits on);
|
||||
// a compound FK uses the table-level form (ADR-0043 D4).
|
||||
let child_column = columns.last().map_or_else(String::new, |c| c.name.clone());
|
||||
foreign_keys.push(consume_fk_reference(&mut items, None, vec![child_column], true));
|
||||
foreign_keys.push(consume_fk_reference(
|
||||
&mut items,
|
||||
None,
|
||||
vec![child_column],
|
||||
true,
|
||||
));
|
||||
}
|
||||
// Table-level `[constraint <name>] foreign key (<col>)
|
||||
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
|
||||
MatchedKind::Word("foreign") => {
|
||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
|
||||
if matches!(
|
||||
items.peek().map(|i| &i.kind),
|
||||
Some(MatchedKind::Word("key"))
|
||||
) {
|
||||
items.next(); // `key`
|
||||
}
|
||||
// `( <child column> [, <child column>]* )` — a compound
|
||||
@@ -1674,7 +1697,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
||||
items.next();
|
||||
}
|
||||
// `references <parent> …`
|
||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
|
||||
if matches!(
|
||||
items.peek().map(|i| &i.kind),
|
||||
Some(MatchedKind::Word("references"))
|
||||
) {
|
||||
items.next();
|
||||
}
|
||||
let fk =
|
||||
@@ -1859,13 +1885,19 @@ where
|
||||
Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade,
|
||||
Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict,
|
||||
Some(MatchedKind::Word("set")) => {
|
||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
|
||||
if matches!(
|
||||
items.peek().map(|i| &i.kind),
|
||||
Some(MatchedKind::Word("null"))
|
||||
) {
|
||||
items.next();
|
||||
}
|
||||
ReferentialAction::SetNull
|
||||
}
|
||||
Some(MatchedKind::Word("no")) => {
|
||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("action"))) {
|
||||
if matches!(
|
||||
items.peek().map(|i| &i.kind),
|
||||
Some(MatchedKind::Word("action"))
|
||||
) {
|
||||
items.next();
|
||||
}
|
||||
ReferentialAction::NoAction
|
||||
@@ -1933,11 +1965,12 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
|
||||
// concrete keyword (`unique index` | `index`) — the trap-safe form (the
|
||||
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The
|
||||
// builder reads `unique` presence via `contains_word("unique")`.
|
||||
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] =
|
||||
&[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))];
|
||||
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("unique")),
|
||||
Node::Word(Word::keyword("index")),
|
||||
];
|
||||
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
|
||||
static SQL_CI_LEAD_CHOICES: &[Node] =
|
||||
&[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
|
||||
static SQL_CI_LEAD_CHOICES: &[Node] = &[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
|
||||
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);
|
||||
|
||||
static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
|
||||
@@ -2104,8 +2137,7 @@ static AT_RENAME_COLUMN_TAIL_NODES: &[Node] = &[
|
||||
NEW_COLUMN_NAME,
|
||||
];
|
||||
const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES);
|
||||
static AT_RENAME_TABLE_TAIL_NODES: &[Node] =
|
||||
&[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
|
||||
static AT_RENAME_TABLE_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
|
||||
const AT_RENAME_TABLE_TAIL: Node = Node::Seq(AT_RENAME_TABLE_TAIL_NODES);
|
||||
static AT_RENAME_TAIL_CHOICES: &[Node] = &[AT_RENAME_COLUMN_TAIL, AT_RENAME_TABLE_TAIL];
|
||||
const AT_RENAME_TAIL: Node = Node::Choice(AT_RENAME_TAIL_CHOICES);
|
||||
@@ -2132,8 +2164,10 @@ static AT_AC_TYPE_NODES: &[Node] = &[
|
||||
super::sql_create_table::SQL_TYPE,
|
||||
];
|
||||
const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES);
|
||||
static AT_AC_NOT_NULL_NODES: &[Node] =
|
||||
&[Node::Word(Word::keyword("not")), Node::Word(Word::keyword("null"))];
|
||||
static AT_AC_NOT_NULL_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("not")),
|
||||
Node::Word(Word::keyword("null")),
|
||||
];
|
||||
const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES);
|
||||
static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("data")),
|
||||
@@ -2149,8 +2183,7 @@ static AT_AC_SET_TAIL_CHOICES: &[Node] = &[
|
||||
const AT_AC_SET_TAIL: Node = Node::Choice(AT_AC_SET_TAIL_CHOICES);
|
||||
static AT_AC_SET_NODES: &[Node] = &[Node::Word(Word::keyword("set")), AT_AC_SET_TAIL];
|
||||
const AT_AC_SET: Node = Node::Seq(AT_AC_SET_NODES);
|
||||
static AT_AC_DROP_TAIL_CHOICES: &[Node] =
|
||||
&[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
|
||||
static AT_AC_DROP_TAIL_CHOICES: &[Node] = &[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
|
||||
const AT_AC_DROP_TAIL: Node = Node::Choice(AT_AC_DROP_TAIL_CHOICES);
|
||||
static AT_AC_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_AC_DROP_TAIL];
|
||||
const AT_AC_DROP: Node = Node::Seq(AT_AC_DROP_NODES);
|
||||
@@ -2258,10 +2291,14 @@ fn build_alter_add_column_spec(
|
||||
let mut items = path.items.iter().peekable();
|
||||
while let Some(item) = items.next() {
|
||||
match &item.kind {
|
||||
MatchedKind::Ident { role: "col_name", .. } => {
|
||||
MatchedKind::Ident {
|
||||
role: "col_name", ..
|
||||
} => {
|
||||
pending_name = Some(item.text.clone());
|
||||
}
|
||||
MatchedKind::Ident { role: "col_type", .. } => {
|
||||
MatchedKind::Ident {
|
||||
role: "col_type", ..
|
||||
} => {
|
||||
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "unknown type".to_string())],
|
||||
@@ -2280,7 +2317,10 @@ fn build_alter_add_column_spec(
|
||||
spec = Some(ColumnSpec::new(name, Type::Real));
|
||||
}
|
||||
MatchedKind::Word("not") => {
|
||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
|
||||
if matches!(
|
||||
items.peek().map(|i| &i.kind),
|
||||
Some(MatchedKind::Word("null"))
|
||||
) {
|
||||
items.next();
|
||||
if let Some(s) = spec.as_mut() {
|
||||
s.not_null = true;
|
||||
@@ -2326,11 +2366,15 @@ fn build_alter_column_type(path: &MatchedPath) -> Result<AlterTableAction, Valid
|
||||
let mut items = path.items.iter().peekable();
|
||||
while let Some(item) = items.next() {
|
||||
match &item.kind {
|
||||
MatchedKind::Ident { role: "col_type", .. } => {
|
||||
ty = Some(Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
|
||||
MatchedKind::Ident {
|
||||
role: "col_type", ..
|
||||
} => {
|
||||
ty = Some(
|
||||
Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "unknown type".to_string())],
|
||||
})?);
|
||||
})?,
|
||||
);
|
||||
}
|
||||
MatchedKind::Word("double") => {
|
||||
if matches!(
|
||||
@@ -2379,7 +2423,10 @@ fn build_alter_column_attr(
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "set default needs a value".to_string())],
|
||||
})?;
|
||||
AlterTableAction::SetColumnDefault { column, default_sql }
|
||||
AlterTableAction::SetColumnDefault {
|
||||
column,
|
||||
default_sql,
|
||||
}
|
||||
}
|
||||
(false, true) => AlterTableAction::DropColumnDefault { column },
|
||||
(true, false) => AlterTableAction::SetColumnNotNull { column },
|
||||
@@ -2495,10 +2542,7 @@ fn build_alter_add_table_constraint(
|
||||
/// Capture the raw SQL text of an `ADD … CHECK (<expr>)` (ADR-0035 §4g).
|
||||
/// `sql_expr` is validate-only, so the expression is captured by byte
|
||||
/// span — the 4a.2 / 4e mechanism.
|
||||
fn capture_table_check_sql(
|
||||
path: &MatchedPath,
|
||||
source: &str,
|
||||
) -> Result<String, ValidationError> {
|
||||
fn capture_table_check_sql(path: &MatchedPath, source: &str) -> Result<String, ValidationError> {
|
||||
let mut items = path.items.iter().peekable();
|
||||
while let Some(item) = items.next() {
|
||||
if matches!(item.kind, MatchedKind::Word("check"))
|
||||
@@ -2528,7 +2572,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
|
||||
items.next();
|
||||
}
|
||||
items.next(); // `foreign`
|
||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
|
||||
if matches!(
|
||||
items.peek().map(|i| &i.kind),
|
||||
Some(MatchedKind::Word("key"))
|
||||
) {
|
||||
items.next();
|
||||
}
|
||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
||||
@@ -2548,7 +2595,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
|
||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
|
||||
items.next();
|
||||
}
|
||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
|
||||
if matches!(
|
||||
items.peek().map(|i| &i.kind),
|
||||
Some(MatchedKind::Word("references"))
|
||||
) {
|
||||
items.next();
|
||||
}
|
||||
// `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form.
|
||||
@@ -2626,7 +2676,10 @@ mod constraint_tests {
|
||||
fn an_unconstrained_create_table_still_parses() {
|
||||
let cols = create_columns("create table T with pk id(serial), name(text)");
|
||||
assert_eq!(cols.len(), 2);
|
||||
assert!(cols.iter().all(|c| !c.not_null && !c.unique && c.default.is_none()));
|
||||
assert!(
|
||||
cols.iter()
|
||||
.all(|c| !c.not_null && !c.unique && c.default.is_none())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2651,7 +2704,9 @@ mod constraint_tests {
|
||||
#[test]
|
||||
fn add_column_parses_a_unique_constraint() {
|
||||
match parse_command("add column to T: email (text) unique").expect("parse") {
|
||||
Command::AddColumn { unique, not_null, .. } => {
|
||||
Command::AddColumn {
|
||||
unique, not_null, ..
|
||||
} => {
|
||||
assert!(unique);
|
||||
assert!(!not_null);
|
||||
}
|
||||
@@ -2682,9 +2737,7 @@ mod constraint_tests {
|
||||
fn check_with_a_parenthesised_sub_expression_parses() {
|
||||
// The check's own parens plus a nested group — the
|
||||
// builder's paren-depth scan must pair them correctly.
|
||||
let cols = create_columns(
|
||||
"create table T with pk n(int) check ((n > 0) or (n < -10))",
|
||||
);
|
||||
let cols = create_columns("create table T with pk n(int) check ((n > 0) or (n < -10))");
|
||||
assert!(cols[0].check.is_some());
|
||||
}
|
||||
|
||||
@@ -2731,8 +2784,7 @@ mod constraint_tests {
|
||||
|
||||
#[test]
|
||||
fn add_constraint_check_parses() {
|
||||
match parse_command("add constraint check (age >= 0) to Users.age").expect("parse")
|
||||
{
|
||||
match parse_command("add constraint check (age >= 0) to Users.age").expect("parse") {
|
||||
Command::AddConstraint {
|
||||
column, constraint, ..
|
||||
} => {
|
||||
@@ -2826,7 +2878,10 @@ mod sql_drop_table_tests {
|
||||
Command::DropColumn { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_command_in_mode("drop relationship Customers_id_to_Orders_CustId", Mode::Advanced)
|
||||
parse_command_in_mode(
|
||||
"drop relationship Customers_id_to_Orders_CustId",
|
||||
Mode::Advanced
|
||||
)
|
||||
.expect("parses"),
|
||||
Command::DropRelationship { .. }
|
||||
));
|
||||
@@ -2932,7 +2987,13 @@ mod sql_create_index_tests {
|
||||
columns,
|
||||
unique,
|
||||
if_not_exists,
|
||||
} => Ci { name, table, columns, unique, if_not_exists },
|
||||
} => Ci {
|
||||
name,
|
||||
table,
|
||||
columns,
|
||||
unique,
|
||||
if_not_exists,
|
||||
},
|
||||
other => panic!("expected SqlCreateIndex, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -3134,7 +3195,9 @@ mod sql_alter_table_tests {
|
||||
// The target slot carries the `reject_internal_table` validator
|
||||
// (mirroring CREATE TABLE), so an `__rdbms_*` target is refused
|
||||
// before submit — engine-neutral, not a raw engine error.
|
||||
assert!(parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err());
|
||||
assert!(
|
||||
parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3213,7 +3276,10 @@ mod sql_alter_table_tests {
|
||||
// alias map still applies through the synonym
|
||||
assert!(matches!(
|
||||
alter("alter table T alter column n set data type double precision").1,
|
||||
AlterTableAction::AlterColumnType { ty: crate::dsl::types::Type::Real, .. }
|
||||
AlterTableAction::AlterColumnType {
|
||||
ty: crate::dsl::types::Type::Real,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
@@ -3238,7 +3304,10 @@ mod sql_alter_table_tests {
|
||||
#[test]
|
||||
fn alter_column_set_default_captures_raw_expr() {
|
||||
match alter("alter table T alter column qty set default 0").1 {
|
||||
AlterTableAction::SetColumnDefault { column, default_sql } => {
|
||||
AlterTableAction::SetColumnDefault {
|
||||
column,
|
||||
default_sql,
|
||||
} => {
|
||||
assert_eq!(column, "qty");
|
||||
assert_eq!(default_sql, "0");
|
||||
}
|
||||
@@ -3317,7 +3386,9 @@ mod sql_alter_table_tests {
|
||||
match alter("alter table T add check (a < b)").1 {
|
||||
AlterTableAction::AddTableConstraint { name, constraint } => {
|
||||
assert_eq!(name, None);
|
||||
assert!(matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b"));
|
||||
assert!(
|
||||
matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b")
|
||||
);
|
||||
}
|
||||
other => panic!("expected AddTableConstraint/Check, got {other:?}"),
|
||||
}
|
||||
@@ -3335,7 +3406,9 @@ mod sql_alter_table_tests {
|
||||
match alter("alter table T add unique (a, b)").1 {
|
||||
AlterTableAction::AddTableConstraint { name, constraint } => {
|
||||
assert_eq!(name, None);
|
||||
assert!(matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()]));
|
||||
assert!(
|
||||
matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()])
|
||||
);
|
||||
}
|
||||
other => panic!("expected AddTableConstraint/Unique, got {other:?}"),
|
||||
}
|
||||
@@ -3352,7 +3425,9 @@ mod sql_alter_table_tests {
|
||||
)
|
||||
.expect_err("a named UNIQUE constraint is refused");
|
||||
assert!(
|
||||
err.to_string().to_lowercase().contains("unique constraint cannot be named"),
|
||||
err.to_string()
|
||||
.to_lowercase()
|
||||
.contains("unique constraint cannot be named"),
|
||||
"expected the builder's named-UNIQUE refusal, got: {err}"
|
||||
);
|
||||
}
|
||||
@@ -3364,7 +3439,9 @@ mod sql_alter_table_tests {
|
||||
let err = parse_command_in_mode("alter table T add primary key (id)", Mode::Advanced)
|
||||
.expect_err("ADD PRIMARY KEY is refused");
|
||||
assert!(
|
||||
err.to_string().to_lowercase().contains("primary key is fixed at creation"),
|
||||
err.to_string()
|
||||
.to_lowercase()
|
||||
.contains("primary key is fixed at creation"),
|
||||
"expected the builder's ADD-PRIMARY-KEY refusal, got: {err}"
|
||||
);
|
||||
}
|
||||
@@ -3392,7 +3469,10 @@ mod sql_alter_table_tests {
|
||||
assert_eq!(name.as_deref(), Some("fk_p"));
|
||||
match *constraint {
|
||||
TableConstraint::ForeignKey(fk) => {
|
||||
assert_eq!(fk.parent_columns, None, "bare reference resolves at execution");
|
||||
assert_eq!(
|
||||
fk.parent_columns, None,
|
||||
"bare reference resolves at execution"
|
||||
);
|
||||
}
|
||||
other => panic!("expected ForeignKey, got {other:?}"),
|
||||
}
|
||||
|
||||
+24
-35
@@ -79,9 +79,9 @@ const EXPR_COLUMN: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: true,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
/// Operand alternatives. The literal keywords (`null` / `true`
|
||||
@@ -126,8 +126,7 @@ fn where_rhs_operand(ctx: &WalkContext) -> Node {
|
||||
// the leak is per distinct column (the walker
|
||||
// memoizes `DynamicSubgrammar` resolution on
|
||||
// `current_column`), not per keystroke.
|
||||
let leaked: &'static str =
|
||||
Box::leak(col.name.clone().into_boxed_str());
|
||||
let leaked: &'static str = Box::leak(col.name.clone().into_boxed_str());
|
||||
Node::TypedValueSlot {
|
||||
ty: col.user_type,
|
||||
column_name: Some(leaked),
|
||||
@@ -260,10 +259,8 @@ static PAREN_GROUP_NODES: &[Node] = &[
|
||||
Node::Subgrammar(&OR_EXPR),
|
||||
Node::Punct(')'),
|
||||
];
|
||||
static BOOL_PRIMARY_CHOICES: &[Node] = &[
|
||||
Node::Seq(PAREN_GROUP_NODES),
|
||||
Node::Subgrammar(&PREDICATE),
|
||||
];
|
||||
static BOOL_PRIMARY_CHOICES: &[Node] =
|
||||
&[Node::Seq(PAREN_GROUP_NODES), Node::Subgrammar(&PREDICATE)];
|
||||
static BOOL_PRIMARY: Node = Node::Choice(BOOL_PRIMARY_CHOICES);
|
||||
|
||||
/// `not_expr := NOT not_expr | bool_primary`.
|
||||
@@ -271,10 +268,7 @@ static NOT_FORM_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("not")),
|
||||
Node::Subgrammar(&NOT_EXPR),
|
||||
];
|
||||
static NOT_EXPR_CHOICES: &[Node] = &[
|
||||
Node::Seq(NOT_FORM_NODES),
|
||||
Node::Subgrammar(&BOOL_PRIMARY),
|
||||
];
|
||||
static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&BOOL_PRIMARY)];
|
||||
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
|
||||
|
||||
/// `and_expr := not_expr ( AND not_expr )*`.
|
||||
@@ -296,10 +290,7 @@ static AND_EXPR: Node = Node::Seq(AND_EXPR_NODES);
|
||||
/// `or_expr := and_expr ( OR and_expr )*` — the fragment entry
|
||||
/// point. `update` / `delete` / `show data` reference this
|
||||
/// through `Node::Subgrammar(&OR_EXPR)`.
|
||||
static OR_TAIL_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("or")),
|
||||
Node::Subgrammar(&AND_EXPR),
|
||||
];
|
||||
static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
|
||||
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
|
||||
static OR_EXPR_NODES: &[Node] = &[
|
||||
Node::Subgrammar(&AND_EXPR),
|
||||
@@ -534,18 +525,18 @@ impl<'a> ExprParser<'a> {
|
||||
let span = item.span;
|
||||
let literal = |value: Value| Operand::Literal { value, span };
|
||||
match &item.kind {
|
||||
MatchedKind::Ident { role: "expr_column", .. } => {
|
||||
Ok(Operand::Column { name: item.text.clone(), span })
|
||||
}
|
||||
MatchedKind::Ident {
|
||||
role: "expr_column",
|
||||
..
|
||||
} => Ok(Operand::Column {
|
||||
name: item.text.clone(),
|
||||
span,
|
||||
}),
|
||||
MatchedKind::Word("null") => Ok(literal(Value::Null)),
|
||||
MatchedKind::Word("true") => Ok(literal(Value::Bool(true))),
|
||||
MatchedKind::Word("false") => Ok(literal(Value::Bool(false))),
|
||||
MatchedKind::NumberLit => {
|
||||
Ok(literal(Value::Number(item.text.clone())))
|
||||
}
|
||||
MatchedKind::StringLit => {
|
||||
Ok(literal(Value::Text(item.text.clone())))
|
||||
}
|
||||
MatchedKind::NumberLit => Ok(literal(Value::Number(item.text.clone()))),
|
||||
MatchedKind::StringLit => Ok(literal(Value::Text(item.text.clone()))),
|
||||
_ => Err(drift_error("expected a column or literal operand")),
|
||||
}
|
||||
}
|
||||
@@ -591,8 +582,7 @@ mod tests {
|
||||
let mut ctx = WalkContext::new();
|
||||
let mut path = MatchedPath::new();
|
||||
let mut per_byte = Vec::new();
|
||||
let result =
|
||||
walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
|
||||
let result = walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
|
||||
match result {
|
||||
NodeWalkResult::Matched { end, .. } => {
|
||||
assert!(
|
||||
@@ -730,8 +720,7 @@ mod tests {
|
||||
negated: false,
|
||||
}),
|
||||
);
|
||||
let Expr::Predicate(Predicate::Like { negated, .. }) =
|
||||
parse_expr("Name not like 'A%'")
|
||||
let Expr::Predicate(Predicate::Like { negated, .. }) = parse_expr("Name not like 'A%'")
|
||||
else {
|
||||
panic!("expected a negated Like");
|
||||
};
|
||||
@@ -794,16 +783,16 @@ mod tests {
|
||||
fn nested_parentheses_round_trip() {
|
||||
// Exercises the Subgrammar recursion a few levels deep.
|
||||
let expr = parse_expr("((a = 1 and b = 2) or (c = 3))");
|
||||
assert!(matches!(expr, Expr::Or(_) | Expr::And(_) | Expr::Predicate(_)));
|
||||
assert!(matches!(
|
||||
expr,
|
||||
Expr::Or(_) | Expr::And(_) | Expr::Predicate(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive_keywords() {
|
||||
// Keywords fold case; the built tree is identical.
|
||||
assert_eq!(
|
||||
parse_expr("a = 1 AND b = 2"),
|
||||
parse_expr("a = 1 and b = 2"),
|
||||
);
|
||||
assert_eq!(parse_expr("a = 1 AND b = 2"), parse_expr("a = 1 and b = 2"),);
|
||||
assert_eq!(
|
||||
parse_expr("Email IS NOT NULL"),
|
||||
parse_expr("Email is not null"),
|
||||
|
||||
+21
-24
@@ -27,9 +27,9 @@ pub mod data;
|
||||
pub mod ddl;
|
||||
pub mod expr;
|
||||
pub mod shared;
|
||||
pub mod sql_expr;
|
||||
pub mod sql_create_table;
|
||||
pub mod sql_delete;
|
||||
pub mod sql_expr;
|
||||
pub mod sql_insert;
|
||||
pub mod sql_select;
|
||||
pub mod sql_update;
|
||||
@@ -328,9 +328,7 @@ pub enum Node {
|
||||
/// A number literal. The optional `validator` runs against
|
||||
/// the matched text (used by Phase D value slots to enforce
|
||||
/// per-type integer/decimal rules).
|
||||
NumberLit {
|
||||
validator: Option<NumberValidator>,
|
||||
},
|
||||
NumberLit { validator: Option<NumberValidator> },
|
||||
/// A literal byte sequence at this position — matches
|
||||
/// bytes verbatim (whitespace-skipped) with a lookahead so
|
||||
/// `1` doesn't half-match `12` and `n` doesn't half-match
|
||||
@@ -701,7 +699,11 @@ fn selected_nodes_for_input_in_mode(
|
||||
.filter(|(_, _, c)| *c == CommandCategory::Simple)
|
||||
.collect()
|
||||
};
|
||||
if selected.is_empty() { candidates } else { selected }
|
||||
if selected.is_empty() {
|
||||
candidates
|
||||
} else {
|
||||
selected
|
||||
}
|
||||
}
|
||||
|
||||
/// The single usage template most relevant to `source`, when
|
||||
@@ -724,10 +726,7 @@ pub fn usage_key_for_input(source: &str) -> Option<&'static str> {
|
||||
/// disambiguates the single most-relevant usage key from the
|
||||
/// mode-selected key set.
|
||||
#[must_use]
|
||||
pub fn usage_key_for_input_in_mode(
|
||||
source: &str,
|
||||
mode: crate::mode::Mode,
|
||||
) -> Option<&'static str> {
|
||||
pub fn usage_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
|
||||
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
|
||||
pick_form_key(source, &keys)
|
||||
}
|
||||
@@ -755,7 +754,10 @@ fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
|
||||
}
|
||||
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
|
||||
// — a letter, so the digit branch misses it; its key ends `…m2n`.
|
||||
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
|
||||
if source[after..]
|
||||
.get(..3)
|
||||
.is_some_and(|s| s.eq_ignore_ascii_case("m:n"))
|
||||
{
|
||||
return keys.iter().copied().find(|k| k.ends_with("m2n"));
|
||||
}
|
||||
// Otherwise the form word is an identifier — `column`, `index`,
|
||||
@@ -770,8 +772,7 @@ fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
|
||||
/// which read the same data through the legacy `usage::REGISTRY`.
|
||||
#[must_use]
|
||||
pub fn entry_words_alphabetised() -> Vec<&'static str> {
|
||||
let mut words: Vec<&'static str> =
|
||||
REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
|
||||
let mut words: Vec<&'static str> = REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
|
||||
words.sort_unstable();
|
||||
words.dedup();
|
||||
words
|
||||
@@ -791,6 +792,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||
(&app::QUIT, CommandCategory::Simple),
|
||||
(&app::HELP, CommandCategory::Simple),
|
||||
(&app::HINT, CommandCategory::Simple),
|
||||
(&app::VERSION, CommandCategory::Simple),
|
||||
(&app::REBUILD, CommandCategory::Simple),
|
||||
(&app::SAVE, CommandCategory::Simple),
|
||||
(&app::NEW, CommandCategory::Simple),
|
||||
@@ -904,9 +906,7 @@ pub fn command_for_entry_word(word: &str) -> Option<(usize, &'static CommandNode
|
||||
/// returns its `Simple` DSL node and `Advanced` SQL node. The
|
||||
/// dispatcher picks among them by the active input mode.
|
||||
#[must_use]
|
||||
pub fn commands_for_entry_word(
|
||||
word: &str,
|
||||
) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
|
||||
pub fn commands_for_entry_word(word: &str) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
|
||||
REGISTRY
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -1009,7 +1009,10 @@ mod hint_key_tests {
|
||||
];
|
||||
for c in classes {
|
||||
let key = format!("hint.err.{c}.what");
|
||||
assert!(cat.get(&key).is_some(), "missing tier-3 error block `{key}`");
|
||||
assert!(
|
||||
cat.get(&key).is_some(),
|
||||
"missing tier-3 error block `{key}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1097,10 +1100,7 @@ mod usage_key_tests {
|
||||
let cases = [
|
||||
("add column to T: c (int)", "parse.usage.add_column"),
|
||||
("add index on T (c)", "parse.usage.add_index"),
|
||||
(
|
||||
"add constraint unique to T.c",
|
||||
"parse.usage.add_constraint",
|
||||
),
|
||||
("add constraint unique to T.c", "parse.usage.add_constraint"),
|
||||
(
|
||||
"drop constraint check from T.c",
|
||||
"parse.usage.drop_constraint",
|
||||
@@ -1117,10 +1117,7 @@ mod usage_key_tests {
|
||||
("drop table T", "parse.usage.drop_table"),
|
||||
("drop column from table T: c", "parse.usage.drop_column"),
|
||||
("drop index i", "parse.usage.drop_index"),
|
||||
(
|
||||
"drop relationship r",
|
||||
"parse.usage.drop_relationship",
|
||||
),
|
||||
("drop relationship r", "parse.usage.drop_relationship"),
|
||||
("show data T", "parse.usage.show_data"),
|
||||
("show table T", "parse.usage.show_table"),
|
||||
// `create` is multi-form (table vs m:n, ADR-0045): each typed
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
use crate::completion::TableColumn;
|
||||
use crate::dsl::grammar::{
|
||||
HighlightClass, HintMode, IdentSource, IdentValidator, Node,
|
||||
NumberValidator, ValidationError, Word,
|
||||
HighlightClass, HintMode, IdentSource, IdentValidator, Node, NumberValidator, ValidationError,
|
||||
Word,
|
||||
};
|
||||
use crate::dsl::types::Type;
|
||||
use crate::dsl::walker::context::WalkContext;
|
||||
@@ -32,10 +32,7 @@ pub fn validate_type_name(value: &str) -> Result<(), ValidationError> {
|
||||
.join(", ");
|
||||
Err(ValidationError {
|
||||
message_key: "parse.custom.unknown_type",
|
||||
args: vec![
|
||||
("found", value.to_string()),
|
||||
("expected", expected),
|
||||
],
|
||||
args: vec![("found", value.to_string()), ("expected", expected)],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -54,9 +51,9 @@ pub const TYPE_SLOT: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
// --- Qualified column reference (`<Table>.<Column>`) --------------
|
||||
@@ -313,9 +310,7 @@ const fn slot_inner_for_type(ty: Type) -> &'static Node {
|
||||
Type::Real => &REAL_SLOT_INNER,
|
||||
Type::Decimal => &DECIMAL_SLOT_INNER,
|
||||
Type::Bool => &BOOL_SLOT_INNER,
|
||||
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => {
|
||||
&TEXT_SLOT_INNER
|
||||
}
|
||||
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => &TEXT_SLOT_INNER,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,9 +392,7 @@ pub(crate) const FALLBACK_VALUE_LIST: Node = Node::Repeated {
|
||||
/// This is the single source of truth shared by [`column_value_list`]
|
||||
/// (which builds the typed slots) and the `data.rs` arity gate (which
|
||||
/// counts them) so the two never disagree (issue #17).
|
||||
pub fn insert_target_columns<'c>(
|
||||
ctx: &'c WalkContext<'_>,
|
||||
) -> Option<Vec<&'c TableColumn>> {
|
||||
pub fn insert_target_columns<'c>(ctx: &'c WalkContext<'_>) -> Option<Vec<&'c TableColumn>> {
|
||||
let table_cols = ctx.current_table_columns.as_ref()?;
|
||||
if table_cols.is_empty() {
|
||||
return None;
|
||||
|
||||
@@ -405,8 +405,14 @@ const TABLE_FK_NAMED: Node = Node::Seq(TABLE_FK_NAMED_NODES);
|
||||
// / `foreign`) that disambiguates it from a column name. (A column
|
||||
// literally named with one of those keywords is therefore unavailable,
|
||||
// the same trade real SQL makes with its reserved words.)
|
||||
static ELEMENT_CHOICES: &[Node] =
|
||||
&[TABLE_PK, TABLE_UNIQUE, TABLE_CHECK, TABLE_FK_NAMED, TABLE_FK, COLUMN_DEF];
|
||||
static ELEMENT_CHOICES: &[Node] = &[
|
||||
TABLE_PK,
|
||||
TABLE_UNIQUE,
|
||||
TABLE_CHECK,
|
||||
TABLE_FK_NAMED,
|
||||
TABLE_FK,
|
||||
COLUMN_DEF,
|
||||
];
|
||||
const ELEMENT_INNER: Node = Node::Choice(ELEMENT_CHOICES);
|
||||
// Issue #4: wrap the element slot in `IntroProse` so a fresh element
|
||||
// position (`create table T (` and after every `,`) surfaces a prose
|
||||
@@ -495,18 +501,31 @@ mod tests {
|
||||
let mut ctx = WalkContext::new();
|
||||
let mut path = MatchedPath::new();
|
||||
let mut per_byte = Vec::new();
|
||||
match walk_node(input, 0, &SQL_CREATE_TABLE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
|
||||
match walk_node(
|
||||
input,
|
||||
0,
|
||||
&SQL_CREATE_TABLE_SHAPE,
|
||||
&mut ctx,
|
||||
&mut path,
|
||||
&mut per_byte,
|
||||
) {
|
||||
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn good(input: &str) {
|
||||
assert!(walks(input), "{input:?} should be a valid CREATE TABLE tail");
|
||||
assert!(
|
||||
walks(input),
|
||||
"{input:?} should be a valid CREATE TABLE tail"
|
||||
);
|
||||
}
|
||||
|
||||
fn bad(input: &str) {
|
||||
assert!(!walks(input), "{input:?} should NOT walk as a complete CREATE TABLE tail");
|
||||
assert!(
|
||||
!walks(input),
|
||||
"{input:?} should NOT walk as a complete CREATE TABLE tail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -638,7 +657,9 @@ mod tests {
|
||||
good("table t (id int, ref int references other(id))");
|
||||
good("table t (id int, ref int references other)"); // bare ref
|
||||
good("table t (id int, ref int references other(id) on delete cascade)");
|
||||
good("table t (id int, ref int references other(id) on update set null on delete restrict)");
|
||||
good(
|
||||
"table t (id int, ref int references other(id) on update set null on delete restrict)",
|
||||
);
|
||||
good("table t (id int, ref int, foreign key (ref) references other(id))");
|
||||
good("table t (id int, ref int, constraint fk_x foreign key (ref) references other(id))");
|
||||
good(
|
||||
@@ -691,7 +712,10 @@ mod builder_tests {
|
||||
assert_eq!(name, "t");
|
||||
assert_eq!(
|
||||
cols,
|
||||
vec![("id".to_string(), Type::Int), ("name".to_string(), Type::Text)]
|
||||
vec![
|
||||
("id".to_string(), Type::Int),
|
||||
("name".to_string(), Type::Text)
|
||||
]
|
||||
);
|
||||
assert!(pk.is_empty(), "no PK declared");
|
||||
assert!(!ine);
|
||||
@@ -740,7 +764,10 @@ mod builder_tests {
|
||||
let (_, cols, _, _) = sct("create table t (a varchar(255), b numeric(10, 2))");
|
||||
assert_eq!(
|
||||
cols,
|
||||
vec![("a".to_string(), Type::Text), ("b".to_string(), Type::Decimal)]
|
||||
vec![
|
||||
("a".to_string(), Type::Text),
|
||||
("b".to_string(), Type::Decimal)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -780,8 +807,7 @@ mod builder_tests {
|
||||
fn redundant_constraints_deduped_off_sole_pk_column() {
|
||||
// ADR-0035 §6.5: advanced mode accepts the redundant spelling
|
||||
// and silently drops the flags off the sole PK column.
|
||||
match parse_command("create table t (id int primary key not null unique)")
|
||||
.expect("parses")
|
||||
match parse_command("create table t (id int primary key not null unique)").expect("parses")
|
||||
{
|
||||
Command::SqlCreateTable {
|
||||
columns,
|
||||
@@ -944,8 +970,7 @@ mod builder_tests {
|
||||
// depth 2, not an element boundary, so the following `check`
|
||||
// is still column-level. A naive "reset on any comma" would
|
||||
// misclassify it as table-level (the §4.2 probe).
|
||||
let (cols, checks) =
|
||||
parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
|
||||
let (cols, checks) = parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
|
||||
assert_eq!(col(&cols, "n").check_sql.as_deref(), Some("n > 0"));
|
||||
assert!(checks.is_empty(), "no table-level CHECK was produced");
|
||||
}
|
||||
@@ -977,8 +1002,7 @@ mod builder_tests {
|
||||
fn table_check_before_a_later_column_is_table_level() {
|
||||
// A CHECK element that appears between columns (not after a
|
||||
// column's type) is table-level even though more columns follow.
|
||||
let (cols, checks) =
|
||||
parse_sct_checks("create table t (a int, check (a > 0), b int)");
|
||||
let (cols, checks) = parse_sct_checks("create table t (a int, check (a > 0), b int)");
|
||||
assert_eq!(checks, vec!["a > 0".to_string()]);
|
||||
assert!(col(&cols, "a").check_sql.is_none() && col(&cols, "b").check_sql.is_none());
|
||||
}
|
||||
@@ -1004,7 +1028,10 @@ mod builder_tests {
|
||||
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
|
||||
assert_eq!(fk.on_delete, ReferentialAction::NoAction);
|
||||
assert_eq!(fk.on_update, ReferentialAction::NoAction);
|
||||
assert!(fk.inline, "a column-level `references` is an inline FK (ADR-0043 D4)");
|
||||
assert!(
|
||||
fk.inline,
|
||||
"a column-level `references` is an inline FK (ADR-0043 D4)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1012,14 +1039,19 @@ mod builder_tests {
|
||||
// The table-level `FOREIGN KEY (...)` form is not inline, so it can
|
||||
// carry a multi-column reference and never triggers the inline
|
||||
// "use the table-level form" hint (ADR-0043 D4).
|
||||
let fks = parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
|
||||
let fks = parse_sct_fks(
|
||||
"create table t (id int, pid int, foreign key (pid) references parent(id))",
|
||||
);
|
||||
assert!(!fks[0].inline, "table-level FOREIGN KEY is not inline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_inline_reference_has_no_parent_column() {
|
||||
let fks = parse_sct_fks("create table t (id int, pid int references parent)");
|
||||
assert_eq!(fks[0].parent_columns, None, "bare REFERENCES — resolved at execution");
|
||||
assert_eq!(
|
||||
fks[0].parent_columns, None,
|
||||
"bare REFERENCES — resolved at execution"
|
||||
);
|
||||
assert_eq!(fks[0].parent_table, "parent");
|
||||
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
|
||||
}
|
||||
@@ -1047,8 +1079,9 @@ mod builder_tests {
|
||||
|
||||
#[test]
|
||||
fn table_level_foreign_key_captured() {
|
||||
let fks =
|
||||
parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
|
||||
let fks = parse_sct_fks(
|
||||
"create table t (id int, pid int, foreign key (pid) references parent(id))",
|
||||
);
|
||||
assert_eq!(fks.len(), 1);
|
||||
assert_eq!(fks[0].name, None);
|
||||
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
|
||||
@@ -1073,8 +1106,20 @@ mod builder_tests {
|
||||
foreign key (a) references p(id), foreign key (b) references q(id))",
|
||||
);
|
||||
assert_eq!(fks.len(), 2);
|
||||
assert_eq!((fks[0].child_columns[0].as_str(), fks[0].parent_table.as_str()), ("a", "p"));
|
||||
assert_eq!((fks[1].child_columns[0].as_str(), fks[1].parent_table.as_str()), ("b", "q"));
|
||||
assert_eq!(
|
||||
(
|
||||
fks[0].child_columns[0].as_str(),
|
||||
fks[0].parent_table.as_str()
|
||||
),
|
||||
("a", "p")
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
fks[1].child_columns[0].as_str(),
|
||||
fks[1].parent_table.as_str()
|
||||
),
|
||||
("b", "q")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1108,7 +1153,12 @@ mod builder_tests {
|
||||
assert_eq!(foreign_keys[0].child_columns, vec!["pid".to_string()]);
|
||||
// the column-level CHECK still attaches to `pid`
|
||||
assert_eq!(
|
||||
columns.iter().find(|c| c.name == "pid").unwrap().check_sql.as_deref(),
|
||||
columns
|
||||
.iter()
|
||||
.find(|c| c.name == "pid")
|
||||
.unwrap()
|
||||
.check_sql
|
||||
.as_deref(),
|
||||
Some("pid > 0")
|
||||
);
|
||||
// the table-level CHECK is captured separately
|
||||
|
||||
@@ -82,7 +82,14 @@ mod tests {
|
||||
let mut ctx = WalkContext::new();
|
||||
let mut path = MatchedPath::new();
|
||||
let mut per_byte = Vec::new();
|
||||
match walk_node(input, 0, &SQL_DELETE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
|
||||
match walk_node(
|
||||
input,
|
||||
0,
|
||||
&SQL_DELETE_SHAPE,
|
||||
&mut ctx,
|
||||
&mut path,
|
||||
&mut per_byte,
|
||||
) {
|
||||
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
@@ -93,7 +100,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn bad(input: &str) {
|
||||
assert!(!walks(input), "{input:?} should NOT walk as a complete DELETE tail");
|
||||
assert!(
|
||||
!walks(input),
|
||||
"{input:?} should NOT walk as a complete DELETE tail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+21
-53
@@ -82,19 +82,16 @@ const EXPR_IDENT: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// or_expr := and_expr ( OR and_expr )* — the fragment entry point
|
||||
// =================================================================
|
||||
|
||||
static OR_TAIL_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("or")),
|
||||
Node::Subgrammar(&AND_EXPR),
|
||||
];
|
||||
static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
|
||||
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
|
||||
static SQL_OR_EXPR_NODES: &[Node] = &[
|
||||
Node::Subgrammar(&AND_EXPR),
|
||||
@@ -140,10 +137,7 @@ static NOT_FORM_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("not")),
|
||||
Node::Subgrammar(&NOT_EXPR),
|
||||
];
|
||||
static NOT_EXPR_CHOICES: &[Node] = &[
|
||||
Node::Seq(NOT_FORM_NODES),
|
||||
Node::Subgrammar(&PREDICATE),
|
||||
];
|
||||
static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&PREDICATE)];
|
||||
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
|
||||
|
||||
// =================================================================
|
||||
@@ -156,10 +150,7 @@ static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
|
||||
// needs. ADR-0026's DSL grammar made the tail mandatory because it
|
||||
// forbade a bare column as a boolean; SQL does not.
|
||||
|
||||
static PREDICATE_NODES: &[Node] = &[
|
||||
Node::Subgrammar(&ADDITIVE),
|
||||
Node::Optional(&PREDICATE_TAIL),
|
||||
];
|
||||
static PREDICATE_NODES: &[Node] = &[Node::Subgrammar(&ADDITIVE), Node::Optional(&PREDICATE_TAIL)];
|
||||
static PREDICATE: Node = Node::Seq(PREDICATE_NODES);
|
||||
|
||||
// ---- cmp_op := <= | <> | >= | != | < | > | = --------------------
|
||||
@@ -181,10 +172,7 @@ static CMP_OP_CHOICES: &[Node] = &[
|
||||
// ---- predicate_tail branches ------------------------------------
|
||||
|
||||
/// `cmp_op additive`.
|
||||
static COMPARE_FORM_NODES: &[Node] = &[
|
||||
Node::Choice(CMP_OP_CHOICES),
|
||||
Node::Subgrammar(&ADDITIVE),
|
||||
];
|
||||
static COMPARE_FORM_NODES: &[Node] = &[Node::Choice(CMP_OP_CHOICES), Node::Subgrammar(&ADDITIVE)];
|
||||
|
||||
/// `IS [NOT] NULL`.
|
||||
static IS_NULL_NODES: &[Node] = &[
|
||||
@@ -265,11 +253,7 @@ static PREDICATE_TAIL: Node = Node::Choice(PREDICATE_TAIL_CHOICES);
|
||||
// additive := multiplicative ( ( + | - | || ) multiplicative )*
|
||||
// =================================================================
|
||||
|
||||
static ADD_OP_CHOICES: &[Node] = &[
|
||||
Node::Punct('+'),
|
||||
Node::Punct('-'),
|
||||
Node::Literal("||"),
|
||||
];
|
||||
static ADD_OP_CHOICES: &[Node] = &[Node::Punct('+'), Node::Punct('-'), Node::Literal("||")];
|
||||
static ADD_TAIL_NODES: &[Node] = &[
|
||||
Node::Choice(ADD_OP_CHOICES),
|
||||
Node::Subgrammar(&MULTIPLICATIVE),
|
||||
@@ -289,15 +273,8 @@ static ADDITIVE: Node = Node::Seq(ADDITIVE_NODES);
|
||||
// multiplicative := unary ( ( * | / | % ) unary )*
|
||||
// =================================================================
|
||||
|
||||
static MUL_OP_CHOICES: &[Node] = &[
|
||||
Node::Punct('*'),
|
||||
Node::Punct('/'),
|
||||
Node::Punct('%'),
|
||||
];
|
||||
static MUL_TAIL_NODES: &[Node] = &[
|
||||
Node::Choice(MUL_OP_CHOICES),
|
||||
Node::Subgrammar(&UNARY),
|
||||
];
|
||||
static MUL_OP_CHOICES: &[Node] = &[Node::Punct('*'), Node::Punct('/'), Node::Punct('%')];
|
||||
static MUL_TAIL_NODES: &[Node] = &[Node::Choice(MUL_OP_CHOICES), Node::Subgrammar(&UNARY)];
|
||||
static MUL_TAIL: Node = Node::Seq(MUL_TAIL_NODES);
|
||||
static MULTIPLICATIVE_NODES: &[Node] = &[
|
||||
Node::Subgrammar(&UNARY),
|
||||
@@ -314,14 +291,8 @@ static MULTIPLICATIVE: Node = Node::Seq(MULTIPLICATIVE_NODES);
|
||||
// =================================================================
|
||||
|
||||
static SIGN_CHOICES: &[Node] = &[Node::Punct('-'), Node::Punct('+')];
|
||||
static UNARY_SIGN_NODES: &[Node] = &[
|
||||
Node::Choice(SIGN_CHOICES),
|
||||
Node::Subgrammar(&UNARY),
|
||||
];
|
||||
static UNARY_CHOICES: &[Node] = &[
|
||||
Node::Seq(UNARY_SIGN_NODES),
|
||||
Node::Subgrammar(&PRIMARY),
|
||||
];
|
||||
static UNARY_SIGN_NODES: &[Node] = &[Node::Choice(SIGN_CHOICES), Node::Subgrammar(&UNARY)];
|
||||
static UNARY_CHOICES: &[Node] = &[Node::Seq(UNARY_SIGN_NODES), Node::Subgrammar(&PRIMARY)];
|
||||
static UNARY: Node = Node::Choice(UNARY_CHOICES);
|
||||
|
||||
// =================================================================
|
||||
@@ -402,10 +373,7 @@ static SIMPLE_CASE_NODES: &[Node] = &[
|
||||
Node::Optional(&ELSE_CLAUSE),
|
||||
Node::Word(Word::keyword("end")),
|
||||
];
|
||||
static CASE_BODY_CHOICES: &[Node] = &[
|
||||
Node::Seq(SEARCHED_CASE_NODES),
|
||||
Node::Seq(SIMPLE_CASE_NODES),
|
||||
];
|
||||
static CASE_BODY_CHOICES: &[Node] = &[Node::Seq(SEARCHED_CASE_NODES), Node::Seq(SIMPLE_CASE_NODES)];
|
||||
static CASE_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("case")),
|
||||
Node::Choice(CASE_BODY_CHOICES),
|
||||
@@ -467,14 +435,11 @@ const QUALIFIED_REF_IDENT: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[
|
||||
Node::Punct('.'),
|
||||
QUALIFIED_REF_IDENT,
|
||||
];
|
||||
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[Node::Punct('.'), QUALIFIED_REF_IDENT];
|
||||
|
||||
static NAME_OR_CALL_TAIL_CHOICES: &[Node] = &[
|
||||
Node::Seq(QUALIFIED_REF_TAIL_NODES),
|
||||
@@ -531,7 +496,10 @@ mod tests {
|
||||
|
||||
/// Assert `input` is *not* a complete SQL expression.
|
||||
fn bad(input: &str) {
|
||||
assert!(!walks(input), "{input:?} should NOT walk as a complete expression");
|
||||
assert!(
|
||||
!walks(input),
|
||||
"{input:?} should NOT walk as a complete expression"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -120,7 +120,10 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
|
||||
listed
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
table_cols.iter().find(|c| c.name.eq_ignore_ascii_case(name)).cloned()
|
||||
table_cols
|
||||
.iter()
|
||||
.find(|c| c.name.eq_ignore_ascii_case(name))
|
||||
.cloned()
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
@@ -148,7 +151,11 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
|
||||
fn tuple_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
|
||||
let cols = target_value_columns(ctx);
|
||||
let (count, closed) = count_tuple_values(source, pos);
|
||||
let arity_ok = if closed { count == cols.len() } else { count <= cols.len() };
|
||||
let arity_ok = if closed {
|
||||
count == cols.len()
|
||||
} else {
|
||||
count <= cols.len()
|
||||
};
|
||||
if !cols.is_empty() && arity_ok {
|
||||
Node::DynamicSubgrammar(sql_value_list)
|
||||
} else {
|
||||
@@ -304,8 +311,10 @@ static DO_UPDATE_NODES: &[Node] = &[
|
||||
/// the enclosing Seq, each branch's FIRST token (`nothing` vs
|
||||
/// `update`) disambiguates, so a non-match of branch 0 is a clean
|
||||
/// `NoMatch` that falls through to branch 1.
|
||||
static DO_ACTION_CHOICES: &[Node] =
|
||||
&[Node::Word(Word::keyword("nothing")), Node::Seq(DO_UPDATE_NODES)];
|
||||
static DO_ACTION_CHOICES: &[Node] = &[
|
||||
Node::Word(Word::keyword("nothing")),
|
||||
Node::Seq(DO_UPDATE_NODES),
|
||||
];
|
||||
// `const` — used by value in `ON_CONFLICT_CLAUSE_NODES`.
|
||||
const DO_ACTION: Node = Node::Choice(DO_ACTION_CHOICES);
|
||||
|
||||
@@ -361,7 +370,14 @@ mod tests {
|
||||
let mut ctx = WalkContext::new();
|
||||
let mut path = MatchedPath::new();
|
||||
let mut per_byte = Vec::new();
|
||||
match walk_node(input, 0, &SQL_INSERT_SHAPE, &mut ctx, &mut path, &mut per_byte) {
|
||||
match walk_node(
|
||||
input,
|
||||
0,
|
||||
&SQL_INSERT_SHAPE,
|
||||
&mut ctx,
|
||||
&mut path,
|
||||
&mut per_byte,
|
||||
) {
|
||||
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
@@ -372,7 +388,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn bad(input: &str) {
|
||||
assert!(!walks(input), "{input:?} should NOT walk as a complete INSERT tail");
|
||||
assert!(
|
||||
!walks(input),
|
||||
"{input:?} should NOT walk as a complete INSERT tail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -418,8 +437,12 @@ mod tests {
|
||||
// 3h: ON CONFLICT … DO NOTHING / DO UPDATE (ADR-0033 §9).
|
||||
good("into t (id, name) values (1, 'x') on conflict (id) do nothing");
|
||||
good("into t (id, name) values (1, 'x') on conflict do nothing");
|
||||
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name");
|
||||
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0");
|
||||
good(
|
||||
"into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name",
|
||||
);
|
||||
good(
|
||||
"into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0",
|
||||
);
|
||||
// Multi-column conflict target + multi-assignment DO UPDATE.
|
||||
good("into t (a, b) values (1, 2) on conflict (a, b) do update set b = excluded.b, a = 9");
|
||||
// ON CONFLICT composes with RETURNING (order: row source,
|
||||
|
||||
@@ -141,8 +141,15 @@ static EMPTY_NOMATCH: Node = Node::Choice(&[]);
|
||||
/// suffix keywords. `as` is not listed — the AS-form alias is a
|
||||
/// separate `Choice` branch that fires before the lookahead.
|
||||
const PROJECTION_FOLLOW_SET: &[&str] = &[
|
||||
"from", "where", "group", "order", "having", "limit",
|
||||
"union", "intersect", "except",
|
||||
"from",
|
||||
"where",
|
||||
"group",
|
||||
"order",
|
||||
"having",
|
||||
"limit",
|
||||
"union",
|
||||
"intersect",
|
||||
"except",
|
||||
// `returning` belongs to an enclosing DML statement
|
||||
// (`INSERT … SELECT … RETURNING …`, ADR-0033 §5), never to a
|
||||
// projection item's bare alias — so a no-FROM SELECT row source
|
||||
@@ -158,9 +165,21 @@ const PROJECTION_FOLLOW_SET: &[&str] = &[
|
||||
/// only when `b` has no alias — `on` is not a base-table name a
|
||||
/// learner would type as an alias.
|
||||
const TABLE_SOURCE_FOLLOW_SET: &[&str] = &[
|
||||
"where", "group", "order", "having", "limit",
|
||||
"union", "intersect", "except",
|
||||
"inner", "left", "right", "full", "cross", "join", "on",
|
||||
"where",
|
||||
"group",
|
||||
"order",
|
||||
"having",
|
||||
"limit",
|
||||
"union",
|
||||
"intersect",
|
||||
"except",
|
||||
"inner",
|
||||
"left",
|
||||
"right",
|
||||
"full",
|
||||
"cross",
|
||||
"join",
|
||||
"on",
|
||||
// `returning` belongs to an enclosing DML statement
|
||||
// (`INSERT … SELECT … FROM t RETURNING …`, ADR-0033 §5), so the
|
||||
// SELECT row source must not read it as table `t`'s bare alias.
|
||||
@@ -172,15 +191,9 @@ fn peek_next_ident_lower(source: &str, pos: usize) -> Option<String> {
|
||||
consume_ident(source, p).map(|(s, e)| source[s..e].to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn projection_bare_alias_factory(
|
||||
_: &WalkContext,
|
||||
source: &str,
|
||||
pos: usize,
|
||||
) -> Node {
|
||||
fn projection_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
|
||||
match peek_next_ident_lower(source, pos) {
|
||||
Some(word)
|
||||
if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) =>
|
||||
{
|
||||
Some(word) if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) => {
|
||||
Node::Subgrammar(&EMPTY_NOMATCH)
|
||||
}
|
||||
Some(_) => PROJECTION_BARE_ALIAS_IDENT,
|
||||
@@ -188,15 +201,9 @@ fn projection_bare_alias_factory(
|
||||
}
|
||||
}
|
||||
|
||||
fn table_source_bare_alias_factory(
|
||||
_: &WalkContext,
|
||||
source: &str,
|
||||
pos: usize,
|
||||
) -> Node {
|
||||
fn table_source_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
|
||||
match peek_next_ident_lower(source, pos) {
|
||||
Some(word)
|
||||
if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) =>
|
||||
{
|
||||
Some(word) if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) => {
|
||||
Node::Subgrammar(&EMPTY_NOMATCH)
|
||||
}
|
||||
Some(_) => TABLE_SOURCE_BARE_ALIAS_IDENT,
|
||||
@@ -237,14 +244,12 @@ const TABLE_SOURCE_BARE_ALIAS_IDENT: Node = Node::Ident {
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: true,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
static PROJECTION_AS_ALIAS_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("as")),
|
||||
PROJECTION_BARE_ALIAS_IDENT,
|
||||
];
|
||||
static PROJECTION_AS_ALIAS_NODES: &[Node] =
|
||||
&[Node::Word(Word::keyword("as")), PROJECTION_BARE_ALIAS_IDENT];
|
||||
static PROJECTION_AS_ALIAS: Node = Node::Seq(PROJECTION_AS_ALIAS_NODES);
|
||||
|
||||
static TABLE_SOURCE_AS_ALIAS_NODES: &[Node] = &[
|
||||
@@ -258,17 +263,14 @@ static PROJECTION_ALIAS_CHOICES: &[Node] = &[
|
||||
Node::Lookahead(projection_bare_alias_factory),
|
||||
];
|
||||
static PROJECTION_ALIAS_CHOICE: Node = Node::Choice(PROJECTION_ALIAS_CHOICES);
|
||||
static PROJECTION_ALIAS_OPTIONAL: Node =
|
||||
Node::Optional(&PROJECTION_ALIAS_CHOICE);
|
||||
static PROJECTION_ALIAS_OPTIONAL: Node = Node::Optional(&PROJECTION_ALIAS_CHOICE);
|
||||
|
||||
static TABLE_SOURCE_ALIAS_CHOICES: &[Node] = &[
|
||||
Node::Subgrammar(&TABLE_SOURCE_AS_ALIAS),
|
||||
Node::Lookahead(table_source_bare_alias_factory),
|
||||
];
|
||||
static TABLE_SOURCE_ALIAS_CHOICE: Node =
|
||||
Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
|
||||
static TABLE_SOURCE_ALIAS_OPTIONAL: Node =
|
||||
Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
|
||||
static TABLE_SOURCE_ALIAS_CHOICE: Node = Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
|
||||
static TABLE_SOURCE_ALIAS_OPTIONAL: Node = Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
|
||||
|
||||
// =================================================================
|
||||
// Projection item
|
||||
@@ -282,16 +284,13 @@ const QUALIFIED_STAR_QUALIFIER: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
static QUALIFIED_STAR_NODES: &[Node] = &[
|
||||
QUALIFIED_STAR_QUALIFIER,
|
||||
Node::Punct('.'),
|
||||
Node::Punct('*'),
|
||||
];
|
||||
static QUALIFIED_STAR_NODES: &[Node] =
|
||||
&[QUALIFIED_STAR_QUALIFIER, Node::Punct('.'), Node::Punct('*')];
|
||||
static QUALIFIED_STAR: Node = Node::Seq(QUALIFIED_STAR_NODES);
|
||||
|
||||
static PROJECTION_EXPR_ITEM_NODES: &[Node] = &[
|
||||
@@ -310,11 +309,7 @@ static PROJECTION_EXPR_ITEM: Node = Node::Seq(PROJECTION_EXPR_ITEM_NODES);
|
||||
/// ambiguity between `t.*` and `sql_expr` (which can match a
|
||||
/// bare `t`), since the walker's `Choice` doesn't backtrack on
|
||||
/// a committed match.
|
||||
fn projection_item_factory(
|
||||
_: &WalkContext,
|
||||
source: &str,
|
||||
pos: usize,
|
||||
) -> Node {
|
||||
fn projection_item_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
|
||||
let p = skip_whitespace(source, pos);
|
||||
let bytes = source.as_bytes();
|
||||
if bytes.get(p) == Some(&b'*') {
|
||||
@@ -363,8 +358,7 @@ static DISTINCT_OR_ALL_CHOICES: &[Node] = &[
|
||||
Node::Word(Word::keyword("all")),
|
||||
];
|
||||
static DISTINCT_OR_ALL_CHOICE: Node = Node::Choice(DISTINCT_OR_ALL_CHOICES);
|
||||
static DISTINCT_OR_ALL_OPTIONAL: Node =
|
||||
Node::Optional(&DISTINCT_OR_ALL_CHOICE);
|
||||
static DISTINCT_OR_ALL_OPTIONAL: Node = Node::Optional(&DISTINCT_OR_ALL_CHOICE);
|
||||
|
||||
// =================================================================
|
||||
// Table source (FROM / JOIN target)
|
||||
@@ -379,8 +373,8 @@ const TABLE_NAME_IDENT: Node = Node::Ident {
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
static TABLE_SOURCE_NODES: &[Node] = &[
|
||||
@@ -395,8 +389,7 @@ static TABLE_SOURCE: Node = Node::Seq(TABLE_SOURCE_NODES);
|
||||
|
||||
const JOIN_WORD: Node = Node::Word(Word::keyword("join"));
|
||||
const ON_WORD: Node = Node::Word(Word::keyword("on"));
|
||||
static OUTER_OPTIONAL: Node =
|
||||
Node::Optional(&Node::Word(Word::keyword("outer")));
|
||||
static OUTER_OPTIONAL: Node = Node::Optional(&Node::Word(Word::keyword("outer")));
|
||||
|
||||
// `INNER JOIN` and bare `JOIN` are split into two Choice
|
||||
// branches so each branch has a distinct leading keyword
|
||||
@@ -585,8 +578,7 @@ static SET_OP_CHOICES: &[Node] = &[
|
||||
];
|
||||
static SET_OP: Node = Node::Choice(SET_OP_CHOICES);
|
||||
|
||||
static SET_OP_TAIL_NODES: &[Node] =
|
||||
&[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
|
||||
static SET_OP_TAIL_NODES: &[Node] = &[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
|
||||
static SET_OP_TAIL: Node = Node::Seq(SET_OP_TAIL_NODES);
|
||||
|
||||
static PLAIN_COMPOUND_NODES: &[Node] = &[
|
||||
@@ -619,8 +611,7 @@ static WITH_PREFIXED_COMPOUND_NODES: &[Node] = &[
|
||||
Node::Subgrammar(&WITH_CLAUSE),
|
||||
Node::Subgrammar(&PLAIN_COMPOUND),
|
||||
];
|
||||
static WITH_PREFIXED_COMPOUND: Node =
|
||||
Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
|
||||
static WITH_PREFIXED_COMPOUND: Node = Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
|
||||
|
||||
static COMPOUND_CHOICES: &[Node] = &[
|
||||
Node::Subgrammar(&WITH_PREFIXED_COMPOUND),
|
||||
@@ -659,9 +650,9 @@ const CTE_COLUMN_IDENT: Node = Node::Ident {
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
|
||||
static CTE_COLUMN_LIST_NODES: &[Node] = &[
|
||||
@@ -674,18 +665,13 @@ static CTE_COLUMN_LIST_NODES: &[Node] = &[
|
||||
RPAREN,
|
||||
];
|
||||
static CTE_COLUMN_LIST_SEQ: Node = Node::Seq(CTE_COLUMN_LIST_NODES);
|
||||
static CTE_COLUMN_LIST_OPTIONAL: Node =
|
||||
Node::Optional(&CTE_COLUMN_LIST_SEQ);
|
||||
static CTE_COLUMN_LIST_OPTIONAL: Node = Node::Optional(&CTE_COLUMN_LIST_SEQ);
|
||||
|
||||
// CTE body recursion pushes a fresh lexical scope frame (ADR-
|
||||
// 0032 §4 / §10.2). Subqueries in `sql_expr.rs` do the same;
|
||||
// the top-level statement's own COMPOUND embedding does not
|
||||
// (it shares the implicit bottom frame).
|
||||
static CTE_BODY_NODES: &[Node] = &[
|
||||
LPAREN,
|
||||
Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND),
|
||||
RPAREN,
|
||||
];
|
||||
static CTE_BODY_NODES: &[Node] = &[LPAREN, Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND), RPAREN];
|
||||
static CTE_BODY: Node = Node::Seq(CTE_BODY_NODES);
|
||||
|
||||
static CTE_DEF_NODES: &[Node] = &[
|
||||
@@ -807,9 +793,7 @@ mod tests {
|
||||
let mut path = MatchedPath::new();
|
||||
let mut per_byte = Vec::new();
|
||||
match walk_node(input, 0, fragment, &mut ctx, &mut path, &mut per_byte) {
|
||||
NodeWalkResult::Matched { end, .. } => {
|
||||
input[end..].trim().is_empty()
|
||||
}
|
||||
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -819,10 +803,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn good(input: &str) {
|
||||
assert!(
|
||||
walks(input),
|
||||
"{input:?} should be a valid SELECT statement"
|
||||
);
|
||||
assert!(walks(input), "{input:?} should be a valid SELECT statement");
|
||||
}
|
||||
|
||||
fn bad(input: &str) {
|
||||
@@ -1051,16 +1032,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn set_op_chain() {
|
||||
good(
|
||||
"select a from t union select b from u intersect select c from v",
|
||||
);
|
||||
good("select a from t union select b from u intersect select c from v");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_op_with_outer_order_by_and_limit() {
|
||||
good(
|
||||
"select a from t union select b from u order by a limit 10",
|
||||
);
|
||||
good("select a from t union select b from u order by a limit 10");
|
||||
}
|
||||
|
||||
// ----- ORDER BY / LIMIT / OFFSET -----
|
||||
@@ -1126,16 +1103,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn recursive_cte() {
|
||||
good(
|
||||
"with recursive r as (select 1 union all select 2) select * from r",
|
||||
);
|
||||
good("with recursive r as (select 1 union all select 2) select * from r");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_ctes() {
|
||||
good(
|
||||
"with a as (select 1), b as (select 2) select * from a union select * from b",
|
||||
);
|
||||
good("with a as (select 1), b as (select 2) select * from a union select * from b");
|
||||
}
|
||||
|
||||
// ----- subquery shapes (recursion through SQL_SELECT_COMPOUND) -----
|
||||
@@ -1147,9 +1120,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn nested_cte_body_with_union() {
|
||||
good(
|
||||
"with x as (select 1 union select 2) select * from x",
|
||||
);
|
||||
good("with x as (select 1 union select 2) select * from x");
|
||||
}
|
||||
|
||||
// ----- case insensitivity / spacing -----
|
||||
@@ -1363,9 +1334,7 @@ mod tests {
|
||||
#[test]
|
||||
fn in_subquery_in_where_clause() {
|
||||
good("select * from t where id in (select user_id from orders)");
|
||||
good(
|
||||
"select * from customers where id not in (select customer_id from blocklist)",
|
||||
);
|
||||
good("select * from customers where id not in (select customer_id from blocklist)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1378,9 +1347,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn nested_subqueries() {
|
||||
good(
|
||||
"select * from t where x in (select y from u where y in (select z from v))",
|
||||
);
|
||||
good("select * from t where x in (select y from u where y in (select z from v))");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1393,8 +1360,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cte_body_references_qualified_columns() {
|
||||
good(
|
||||
"with x as (select t.name, t.age from t) select x.name from x",
|
||||
);
|
||||
good("with x as (select t.name, t.age from t) select x.name from x");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,14 @@ mod tests {
|
||||
let mut ctx = WalkContext::new();
|
||||
let mut path = MatchedPath::new();
|
||||
let mut per_byte = Vec::new();
|
||||
match walk_node(input, 0, &SQL_UPDATE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
|
||||
match walk_node(
|
||||
input,
|
||||
0,
|
||||
&SQL_UPDATE_SHAPE,
|
||||
&mut ctx,
|
||||
&mut path,
|
||||
&mut per_byte,
|
||||
) {
|
||||
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
@@ -130,7 +137,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn bad(input: &str) {
|
||||
assert!(!walks(input), "{input:?} should NOT walk as a complete UPDATE tail");
|
||||
assert!(
|
||||
!walks(input),
|
||||
"{input:?} should NOT walk as a complete UPDATE tail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+3
-3
@@ -21,9 +21,9 @@ pub mod walker;
|
||||
|
||||
pub use action::ReferentialAction;
|
||||
pub use command::{
|
||||
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr,
|
||||
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
|
||||
ShowListKind, SqlForeignKey,
|
||||
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope,
|
||||
Expr, IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector,
|
||||
RowFilter, ShowListKind, SqlForeignKey,
|
||||
};
|
||||
pub use parser::{ParseError, parse_command};
|
||||
pub use types::Type;
|
||||
|
||||
+18
-25
@@ -55,10 +55,9 @@ pub enum ParseError {
|
||||
impl std::fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Invalid { message, .. } => f.write_str(&crate::t!(
|
||||
"parse.error_wrapper",
|
||||
detail = message,
|
||||
)),
|
||||
Self::Invalid { message, .. } => {
|
||||
f.write_str(&crate::t!("parse.error_wrapper", detail = message,))
|
||||
}
|
||||
Self::Empty => f.write_str(&crate::t!("parse.empty")),
|
||||
}
|
||||
}
|
||||
@@ -125,10 +124,7 @@ pub fn parse_command_with_schema(
|
||||
/// Schemaless, mode-aware parse (ADR-0030 §2). In `Mode::Simple`
|
||||
/// the walker gates SQL-only commands and produces the
|
||||
/// "this is SQL" hint instead of executing them.
|
||||
pub fn parse_command_in_mode(
|
||||
input: &str,
|
||||
mode: Mode,
|
||||
) -> Result<Command, ParseError> {
|
||||
pub fn parse_command_in_mode(input: &str, mode: Mode) -> Result<Command, ParseError> {
|
||||
parse_command_inner(input, None, mode)
|
||||
}
|
||||
|
||||
@@ -185,10 +181,8 @@ fn unknown_command_error(source: &str) -> ParseError {
|
||||
.collect();
|
||||
let joined = oxford_join(&entries);
|
||||
let start = skip_whitespace(source, 0);
|
||||
let (position, found_word) = consume_ident(source, start).map_or_else(
|
||||
|| (start, None),
|
||||
|(s, e)| (s, Some(&source[s..e])),
|
||||
);
|
||||
let (position, found_word) = consume_ident(source, start)
|
||||
.map_or_else(|| (start, None), |(s, e)| (s, Some(&source[s..e])));
|
||||
let message = found_word.map_or_else(
|
||||
|| format!("expected one of {joined}"),
|
||||
|w| format!("expected one of {joined}, found `{w}`"),
|
||||
@@ -1034,19 +1028,22 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"),
|
||||
ok(
|
||||
"add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"
|
||||
),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
ok("add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"),
|
||||
ok(
|
||||
"add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"
|
||||
),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_relationship_repeated_clause_errors() {
|
||||
let e =
|
||||
err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
|
||||
let e = err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
|
||||
match e {
|
||||
ParseError::Invalid { message, .. } => {
|
||||
assert!(message.contains("specified twice"), "{message}");
|
||||
@@ -1073,7 +1070,9 @@ mod tests {
|
||||
#[test]
|
||||
fn add_relationship_with_name_actions_and_flag() {
|
||||
assert_eq!(
|
||||
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"),
|
||||
ok(
|
||||
"add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"
|
||||
),
|
||||
rel(
|
||||
Some("cust_orders"),
|
||||
("Customers", "Id"),
|
||||
@@ -1300,10 +1299,7 @@ mod tests {
|
||||
#[test]
|
||||
fn advanced_ambiguous_update_routes_to_sql() {
|
||||
assert!(matches!(
|
||||
parse_command_in_mode(
|
||||
"update Orders set total = 0 where id = 1",
|
||||
Mode::Advanced,
|
||||
),
|
||||
parse_command_in_mode("update Orders set total = 0 where id = 1", Mode::Advanced,),
|
||||
Ok(Command::SqlUpdate { .. })
|
||||
));
|
||||
}
|
||||
@@ -1399,10 +1395,7 @@ mod tests {
|
||||
// in advanced mode)" pointer is added at the hint layer
|
||||
// (input_render), not in the parsed command/error here.
|
||||
assert!(matches!(
|
||||
parse_command_in_mode(
|
||||
"delete from Orders where id = 1 returning *",
|
||||
Mode::Simple,
|
||||
),
|
||||
parse_command_in_mode("delete from Orders where id = 1 returning *", Mode::Simple,),
|
||||
Err(ParseError::Invalid { .. })
|
||||
));
|
||||
}
|
||||
|
||||
+1
-2
@@ -9,8 +9,7 @@ use rand::RngExt;
|
||||
|
||||
/// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded
|
||||
/// because they are easily confused in print.
|
||||
const ALPHABET: &[u8; 58] =
|
||||
b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
const DEFAULT_LEN: usize = 10;
|
||||
|
||||
|
||||
@@ -43,29 +43,9 @@
|
||||
/// - **Broader scalars:** `date`, `datetime`, `hex`, `ifnull`,
|
||||
/// `instr`, `nullif`, `random`, `replace`, `strftime`, `typeof`.
|
||||
pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
|
||||
"abs",
|
||||
"avg",
|
||||
"coalesce",
|
||||
"count",
|
||||
"date",
|
||||
"datetime",
|
||||
"hex",
|
||||
"ifnull",
|
||||
"instr",
|
||||
"length",
|
||||
"lower",
|
||||
"max",
|
||||
"min",
|
||||
"nullif",
|
||||
"random",
|
||||
"replace",
|
||||
"round",
|
||||
"strftime",
|
||||
"substr",
|
||||
"sum",
|
||||
"trim",
|
||||
"typeof",
|
||||
"upper",
|
||||
"abs", "avg", "coalesce", "count", "date", "datetime", "hex", "ifnull", "instr", "length",
|
||||
"lower", "max", "min", "nullif", "random", "replace", "round", "strftime", "substr", "sum",
|
||||
"trim", "typeof", "upper",
|
||||
];
|
||||
|
||||
/// Whether `partial` is a case-insensitive prefix of at least one
|
||||
@@ -80,9 +60,7 @@ pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
|
||||
#[must_use]
|
||||
pub fn is_known_function_prefix(partial: &str) -> bool {
|
||||
let lowered = partial.to_lowercase();
|
||||
KNOWN_SQL_FUNCTIONS
|
||||
.iter()
|
||||
.any(|f| f.starts_with(&lowered))
|
||||
KNOWN_SQL_FUNCTIONS.iter().any(|f| f.starts_with(&lowered))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+2
-9
@@ -59,11 +59,7 @@ impl Type {
|
||||
#[must_use]
|
||||
pub const fn sqlite_strict_type(self) -> &'static str {
|
||||
match self {
|
||||
Self::Text
|
||||
| Self::ShortId
|
||||
| Self::Decimal
|
||||
| Self::Date
|
||||
| Self::DateTime => "TEXT",
|
||||
Self::Text | Self::ShortId | Self::Decimal | Self::Date | Self::DateTime => "TEXT",
|
||||
Self::Int | Self::Serial | Self::Bool => "INTEGER",
|
||||
Self::Real => "REAL",
|
||||
Self::Blob => "BLOB",
|
||||
@@ -107,10 +103,7 @@ impl Type {
|
||||
/// match against a numeric column (ADR-0027, Amendment 1).
|
||||
#[must_use]
|
||||
pub const fn is_numeric(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Int | Self::Real | Self::Decimal | Self::Serial
|
||||
)
|
||||
matches!(self, Self::Int | Self::Real | Self::Decimal | Self::Serial)
|
||||
}
|
||||
|
||||
/// The user-facing type that an FK column should use to
|
||||
|
||||
+33
-14
@@ -129,13 +129,14 @@ impl Value {
|
||||
|
||||
fn bind_int(&self, column: &str, ty: Type) -> Result<Bound, ValueError> {
|
||||
match self {
|
||||
Self::Number(n) => n
|
||||
.parse::<i64>()
|
||||
Self::Number(n) => {
|
||||
n.parse::<i64>()
|
||||
.map(Bound::Integer)
|
||||
.map_err(|_| ValueError::Format {
|
||||
column: column.to_string(),
|
||||
message: format!("`{n}` is not a valid {ty} (whole number expected)"),
|
||||
}),
|
||||
})
|
||||
}
|
||||
other => Err(ValueError::TypeMismatch {
|
||||
column: column.to_string(),
|
||||
expected_human: format!("a whole number for `{ty}`"),
|
||||
@@ -241,9 +242,7 @@ pub(crate) fn validate_date(s: &str) -> Result<(), String> {
|
||||
// Expect YYYY-MM-DD: 10 chars, two dashes at fixed positions.
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
|
||||
return Err(format!(
|
||||
"`{s}` is not a date in `YYYY-MM-DD` form"
|
||||
));
|
||||
return Err(format!("`{s}` is not a date in `YYYY-MM-DD` form"));
|
||||
}
|
||||
let year = parse_digits(&s[0..4]).ok_or_else(|| format!("`{s}`: invalid year"))?;
|
||||
let month = parse_digits(&s[5..7]).ok_or_else(|| format!("`{s}`: invalid month"))?;
|
||||
@@ -272,7 +271,9 @@ pub(crate) fn validate_datetime(s: &str) -> Result<(), String> {
|
||||
validate_date(date_part)?;
|
||||
let bytes = s.as_bytes();
|
||||
if bytes[10] != b'T' {
|
||||
return Err(format!("`{s}`: missing `T` separator between date and time"));
|
||||
return Err(format!(
|
||||
"`{s}`: missing `T` separator between date and time"
|
||||
));
|
||||
}
|
||||
if bytes[13] != b':' || bytes[16] != b':' {
|
||||
return Err(format!("`{s}`: time portion must be `HH:MM:SS`"));
|
||||
@@ -326,8 +327,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn integer_for_int_column() {
|
||||
assert_eq!(n("42").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(42));
|
||||
assert_eq!(n("-7").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(-7));
|
||||
assert_eq!(
|
||||
n("42").bind_for_column("c", Type::Int).unwrap(),
|
||||
Bound::Integer(42)
|
||||
);
|
||||
assert_eq!(
|
||||
n("-7").bind_for_column("c", Type::Int).unwrap(),
|
||||
Bound::Integer(-7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -355,7 +362,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn shortid_validation_runs_on_text_for_shortid_column() {
|
||||
let err = t("toolong_xyz_more").bind_for_column("c", Type::ShortId).unwrap_err();
|
||||
let err = t("toolong_xyz_more")
|
||||
.bind_for_column("c", Type::ShortId)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ValueError::Format { .. }));
|
||||
|
||||
// Well-formed shortid binds fine.
|
||||
@@ -367,8 +376,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bool_for_bool_column_maps_to_zero_or_one() {
|
||||
assert_eq!(Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(1));
|
||||
assert_eq!(Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(0));
|
||||
assert_eq!(
|
||||
Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(),
|
||||
Bound::Integer(1)
|
||||
);
|
||||
assert_eq!(
|
||||
Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(),
|
||||
Bound::Integer(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -377,13 +392,17 @@ mod tests {
|
||||
t("2025-01-15").bind_for_column("c", Type::Date).unwrap(),
|
||||
Bound::Text("2025-01-15".to_string())
|
||||
);
|
||||
let err = t("2025/01/15").bind_for_column("c", Type::Date).unwrap_err();
|
||||
let err = t("2025/01/15")
|
||||
.bind_for_column("c", Type::Date)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ValueError::Format { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_range_check() {
|
||||
let err = t("2025-13-01").bind_for_column("c", Type::Date).unwrap_err();
|
||||
let err = t("2025-13-01")
|
||||
.bind_for_column("c", Type::Date)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ValueError::Format { message, .. } if message.contains("month")));
|
||||
}
|
||||
|
||||
|
||||
+114
-174
@@ -28,12 +28,10 @@ use crate::completion::TableColumn;
|
||||
use crate::dsl::grammar::{HighlightClass, Node, ValidationError};
|
||||
use crate::dsl::walker::context::WalkContext;
|
||||
use crate::dsl::walker::lex_helpers::{
|
||||
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
|
||||
consume_string_literal, skip_whitespace,
|
||||
};
|
||||
use crate::dsl::walker::outcome::{
|
||||
ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath,
|
||||
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
|
||||
skip_whitespace,
|
||||
};
|
||||
use crate::dsl::walker::outcome::{ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath};
|
||||
|
||||
/// Maximum nesting of `Node::Subgrammar` frames (ADR-0026 §1).
|
||||
///
|
||||
@@ -77,10 +75,7 @@ static DYNAMIC_CACHE: LazyLock<Mutex<HashMap<DynamicKey, &'static Node>>> =
|
||||
/// Resolve a `DynamicSubgrammar` factory to a `&'static Node`,
|
||||
/// reusing a previously-leaked Node when the factory's inputs
|
||||
/// match a cached entry.
|
||||
fn resolve_dynamic(
|
||||
factory: fn(&WalkContext) -> Node,
|
||||
ctx: &WalkContext,
|
||||
) -> &'static Node {
|
||||
fn resolve_dynamic(factory: fn(&WalkContext) -> Node, ctx: &WalkContext) -> &'static Node {
|
||||
let key = DynamicKey {
|
||||
factory: factory as usize,
|
||||
current_table_columns: ctx.current_table_columns.clone(),
|
||||
@@ -123,10 +118,7 @@ pub enum NodeWalkResult {
|
||||
expected: Vec<Expectation>,
|
||||
},
|
||||
/// Committed and hit a hard mismatch or validator failure.
|
||||
Failed {
|
||||
position: usize,
|
||||
kind: FailureKind,
|
||||
},
|
||||
Failed { position: usize, kind: FailureKind },
|
||||
}
|
||||
|
||||
const fn matched(end: usize) -> NodeWalkResult {
|
||||
@@ -218,9 +210,7 @@ fn walk_node_inner(
|
||||
kind: FailureKind::Mismatch { expected: vec![] },
|
||||
}
|
||||
}
|
||||
Node::Subgrammar(inner) => {
|
||||
walk_subgrammar(source, pos, inner, ctx, path, per_byte)
|
||||
}
|
||||
Node::Subgrammar(inner) => walk_subgrammar(source, pos, inner, ctx, path, per_byte),
|
||||
Node::ScopedSubgrammar(inner) => {
|
||||
walk_scoped_subgrammar(source, pos, inner, ctx, path, per_byte)
|
||||
}
|
||||
@@ -247,8 +237,7 @@ fn walk_node_inner(
|
||||
// DynamicSubgrammar wrapper that delegates to the
|
||||
// memoized `column_value_list`), so the per-walk
|
||||
// leak is a few bytes, not a whole typed tree.
|
||||
let resolved: &'static Node =
|
||||
Box::leak(Box::new(factory(ctx, source, pos)));
|
||||
let resolved: &'static Node = Box::leak(Box::new(factory(ctx, source, pos)));
|
||||
walk_node(source, pos, resolved, ctx, path, per_byte)
|
||||
}
|
||||
Node::SetColumn(col) => {
|
||||
@@ -262,7 +251,10 @@ fn walk_node_inner(
|
||||
let col: &crate::completion::TableColumn = col;
|
||||
ctx.current_column = Some(col.clone());
|
||||
ctx.pending_value_column = Some(col.name.clone());
|
||||
NodeWalkResult::Matched { end: pos, skipped: Vec::new() }
|
||||
NodeWalkResult::Matched {
|
||||
end: pos,
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
Node::TypedValueSlot {
|
||||
ty,
|
||||
@@ -342,7 +334,10 @@ fn walk_word(
|
||||
// Amendment 4). Plain keywords leave it `None`.
|
||||
class: word.highlight_override.unwrap_or(HighlightClass::Keyword),
|
||||
});
|
||||
NodeWalkResult::Matched { end, skipped: Vec::new() }
|
||||
NodeWalkResult::Matched {
|
||||
end,
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
} else {
|
||||
NodeWalkResult::NoMatch {
|
||||
position,
|
||||
@@ -477,9 +472,7 @@ fn walk_ident(
|
||||
// ScopedSubgrammar (which is structurally guaranteed to be
|
||||
// the CTE body — no intervening scoped subgrammar in CTE
|
||||
// syntax) runs the harvest at body-frame exit.
|
||||
if writes_cte_name
|
||||
&& let Some(frame) = ctx.from_scope_stack.last_mut()
|
||||
{
|
||||
if writes_cte_name && let Some(frame) = ctx.from_scope_stack.last_mut() {
|
||||
frame
|
||||
.cte_bindings
|
||||
.push(crate::dsl::walker::context::CteBinding {
|
||||
@@ -487,8 +480,7 @@ fn walk_ident(
|
||||
columns: Vec::new(),
|
||||
});
|
||||
let placeholder_index = frame.cte_bindings.len() - 1;
|
||||
ctx.pending_cte_harvest =
|
||||
Some(crate::dsl::walker::context::PendingCteHarvest {
|
||||
ctx.pending_cte_harvest = Some(crate::dsl::walker::context::PendingCteHarvest {
|
||||
placeholder_index,
|
||||
col_list: Vec::new(),
|
||||
cte_name: text.clone(),
|
||||
@@ -507,9 +499,7 @@ fn walk_ident(
|
||||
}
|
||||
// ADR-0032 §10.4: projection-list alias accumulator for
|
||||
// ORDER BY completion candidates.
|
||||
if writes_projection_alias
|
||||
&& let Some(frame) = ctx.from_scope_stack.last_mut()
|
||||
{
|
||||
if writes_projection_alias && let Some(frame) = ctx.from_scope_stack.last_mut() {
|
||||
frame.projection_aliases.push(text.clone());
|
||||
}
|
||||
if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
|
||||
@@ -529,9 +519,7 @@ fn walk_ident(
|
||||
.map(|c| c.name.clone())
|
||||
.or_else(|| Some(text.clone()));
|
||||
}
|
||||
if writes_user_listed_column
|
||||
&& matches!(src, crate::dsl::grammar::IdentSource::Columns)
|
||||
{
|
||||
if writes_user_listed_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
|
||||
// Form A: `insert into <T> (col1, col2, …)`. Append the
|
||||
// matched column name to user_listed_columns so the
|
||||
// inner `values (…)` slot list mirrors the user's
|
||||
@@ -564,7 +552,10 @@ fn walk_ident(
|
||||
// (issue #8 / ADR-0022 Amendment 4).
|
||||
class: highlight_override.unwrap_or(HighlightClass::Identifier),
|
||||
});
|
||||
NodeWalkResult::Matched { end, skipped: Vec::new() }
|
||||
NodeWalkResult::Matched {
|
||||
end,
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_string_lit(
|
||||
@@ -648,7 +639,10 @@ fn walk_literal(
|
||||
end,
|
||||
class,
|
||||
});
|
||||
NodeWalkResult::Matched { end, skipped: Vec::new() }
|
||||
NodeWalkResult::Matched {
|
||||
end,
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_number_lit(
|
||||
@@ -683,7 +677,10 @@ fn walk_number_lit(
|
||||
end,
|
||||
class: HighlightClass::Number,
|
||||
});
|
||||
NodeWalkResult::Matched { end, skipped: Vec::new() }
|
||||
NodeWalkResult::Matched {
|
||||
end,
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_flag(
|
||||
@@ -717,7 +714,10 @@ fn walk_flag(
|
||||
end,
|
||||
class: HighlightClass::Flag,
|
||||
});
|
||||
NodeWalkResult::Matched { end, skipped: Vec::new() }
|
||||
NodeWalkResult::Matched {
|
||||
end,
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -784,7 +784,10 @@ fn walk_repeated(
|
||||
count += 1;
|
||||
last_item_skipped = skipped;
|
||||
}
|
||||
NodeWalkResult::NoMatch { expected, position: inner_pos } => {
|
||||
NodeWalkResult::NoMatch {
|
||||
expected,
|
||||
position: inner_pos,
|
||||
} => {
|
||||
// Mid-typing-the-next-item recovery: if the
|
||||
// separator just consumed and the inner failed
|
||||
// at EOF, the user is partway through typing the
|
||||
@@ -860,7 +863,10 @@ fn walk_bare_path(
|
||||
end,
|
||||
class: HighlightClass::String,
|
||||
});
|
||||
NodeWalkResult::Matched { end, skipped: Vec::new() }
|
||||
NodeWalkResult::Matched {
|
||||
end,
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_choice(
|
||||
@@ -1031,7 +1037,10 @@ fn walk_optional(
|
||||
skipped: expected,
|
||||
}
|
||||
}
|
||||
NodeWalkResult::Incomplete { position: p, expected } if !inner_committed => {
|
||||
NodeWalkResult::Incomplete {
|
||||
position: p,
|
||||
expected,
|
||||
} if !inner_committed => {
|
||||
// Inner reported Incomplete without consuming
|
||||
// anything — same as NoMatch from the user's
|
||||
// perspective. Roll back and skip.
|
||||
@@ -1156,9 +1165,7 @@ fn walk_scoped_subgrammar(
|
||||
// walks that NoMatch / Incomplete / Fail leave the placeholder
|
||||
// empty (the outer-frame state is also discarded in the
|
||||
// speculative path, so this is correct).
|
||||
if let (Some(req), NodeWalkResult::Matched { end, .. }) =
|
||||
(pending_cte, &result)
|
||||
{
|
||||
if let (Some(req), NodeWalkResult::Matched { end, .. }) = (pending_cte, &result) {
|
||||
run_cte_harvest(ctx, path, source, pos, *end, &req);
|
||||
}
|
||||
|
||||
@@ -1240,9 +1247,8 @@ fn run_cte_harvest(
|
||||
select_idx = Some(i + 1); // start of projection list
|
||||
}
|
||||
MatchedKind::Word(
|
||||
"from" | "where" | "group" | "having" | "order"
|
||||
| "limit" | "offset" | "union" | "intersect"
|
||||
| "except",
|
||||
"from" | "where" | "group" | "having" | "order" | "limit" | "offset" | "union"
|
||||
| "intersect" | "except",
|
||||
) if select_idx.is_some() => {
|
||||
end_idx = i;
|
||||
break;
|
||||
@@ -1281,12 +1287,7 @@ fn run_cte_harvest(
|
||||
// Classify each projection item per ADR-0032 §10.3.
|
||||
let mut derived: Vec<CteColumn> = Vec::new();
|
||||
for slice in item_slices {
|
||||
classify_projection_item(
|
||||
slice,
|
||||
body_frame,
|
||||
&ctx.from_scope_stack,
|
||||
&mut derived,
|
||||
);
|
||||
classify_projection_item(slice, body_frame, &ctx.from_scope_stack, &mut derived);
|
||||
}
|
||||
|
||||
// Apply (c1, c2, …) positional rename if provided. Types
|
||||
@@ -1339,8 +1340,7 @@ fn run_cte_harvest(
|
||||
let stack_len = ctx.from_scope_stack.len();
|
||||
if stack_len >= 2
|
||||
&& let Some(outer) = ctx.from_scope_stack.get_mut(stack_len - 2)
|
||||
&& let Some(placeholder) =
|
||||
outer.cte_bindings.get_mut(req.placeholder_index)
|
||||
&& let Some(placeholder) = outer.cte_bindings.get_mut(req.placeholder_index)
|
||||
{
|
||||
placeholder.columns = derived;
|
||||
}
|
||||
@@ -1368,9 +1368,7 @@ fn classify_projection_item(
|
||||
// empty because it wasn't a base-table lookup), resolve
|
||||
// through to the in-scope CteBinding so nested CTEs project
|
||||
// correctly.
|
||||
if expr_slice.len() == 1
|
||||
&& matches!(expr_slice[0].kind, MatchedKind::Punct('*'))
|
||||
{
|
||||
if expr_slice.len() == 1 && matches!(expr_slice[0].kind, MatchedKind::Punct('*')) {
|
||||
for binding in &body_frame.from_scope {
|
||||
for col in expand_binding(binding, scope_stack) {
|
||||
out.push(col);
|
||||
@@ -1383,7 +1381,10 @@ fn classify_projection_item(
|
||||
if expr_slice.len() == 3
|
||||
&& matches!(
|
||||
expr_slice[0].kind,
|
||||
MatchedKind::Ident { role: "qualified_star_qualifier", .. }
|
||||
MatchedKind::Ident {
|
||||
role: "qualified_star_qualifier",
|
||||
..
|
||||
}
|
||||
)
|
||||
&& matches!(expr_slice[1].kind, MatchedKind::Punct('.'))
|
||||
&& matches!(expr_slice[2].kind, MatchedKind::Punct('*'))
|
||||
@@ -1413,11 +1414,7 @@ fn classify_projection_item(
|
||||
)
|
||||
{
|
||||
let col_text = &expr_slice[0].text;
|
||||
let resolved_type = resolve_bare_column_type_in_frame(
|
||||
body_frame,
|
||||
scope_stack,
|
||||
col_text,
|
||||
);
|
||||
let resolved_type = resolve_bare_column_type_in_frame(body_frame, scope_stack, col_text);
|
||||
let name = alias.unwrap_or_else(|| col_text.clone());
|
||||
out.push(CteColumn {
|
||||
name: Some(name),
|
||||
@@ -1447,12 +1444,7 @@ fn classify_projection_item(
|
||||
{
|
||||
let qual = &expr_slice[0].text;
|
||||
let col_text = &expr_slice[2].text;
|
||||
let resolved_type = resolve_qualified_column_type(
|
||||
body_frame,
|
||||
scope_stack,
|
||||
qual,
|
||||
col_text,
|
||||
);
|
||||
let resolved_type = resolve_qualified_column_type(body_frame, scope_stack, qual, col_text);
|
||||
let name = alias.unwrap_or_else(|| col_text.clone());
|
||||
out.push(CteColumn {
|
||||
name: Some(name),
|
||||
@@ -1493,16 +1485,8 @@ fn strip_trailing_alias<'a>(
|
||||
}
|
||||
) {
|
||||
// Optional preceding `AS` keyword.
|
||||
if slice.len() >= 2
|
||||
&& matches!(
|
||||
slice[slice.len() - 2].kind,
|
||||
MatchedKind::Word("as")
|
||||
)
|
||||
{
|
||||
return (
|
||||
&slice[..slice.len() - 2],
|
||||
Some(last.text.clone()),
|
||||
);
|
||||
if slice.len() >= 2 && matches!(slice[slice.len() - 2].kind, MatchedKind::Word("as")) {
|
||||
return (&slice[..slice.len() - 2], Some(last.text.clone()));
|
||||
}
|
||||
return (&slice[..slice.len() - 1], Some(last.text.clone()));
|
||||
}
|
||||
@@ -1613,8 +1597,8 @@ fn merge_expected(dst: &mut Vec<Expectation>, src: Vec<Expectation>) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult,
|
||||
resolve_dynamic, walk_node,
|
||||
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult, resolve_dynamic,
|
||||
walk_node,
|
||||
};
|
||||
use crate::dsl::grammar::{Node, Word};
|
||||
use crate::dsl::walker::context::WalkContext;
|
||||
@@ -1629,18 +1613,14 @@ mod tests {
|
||||
Node::Subgrammar(&NESTED),
|
||||
Node::Punct(')'),
|
||||
];
|
||||
static NESTED_CHOICES: &[Node] = &[
|
||||
Node::Seq(NESTED_GROUP),
|
||||
Node::Word(Word::keyword("x")),
|
||||
];
|
||||
static NESTED_CHOICES: &[Node] = &[Node::Seq(NESTED_GROUP), Node::Word(Word::keyword("x"))];
|
||||
static NESTED: Node = Node::Choice(NESTED_CHOICES);
|
||||
|
||||
fn walk_nested(input: &str) -> NodeWalkResult {
|
||||
let mut ctx = WalkContext::new();
|
||||
let mut path = MatchedPath::new();
|
||||
let mut per_byte = Vec::new();
|
||||
let result =
|
||||
walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
|
||||
let result = walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
|
||||
assert_eq!(
|
||||
ctx.subgrammar_depth, 0,
|
||||
"subgrammar_depth must be restored to 0 after the walk",
|
||||
@@ -1726,14 +1706,8 @@ mod tests {
|
||||
fn resolve_dynamic_cache_is_populated() {
|
||||
let ctx = WalkContext::new();
|
||||
let _ = resolve_dynamic(const_factory, &ctx);
|
||||
let populated = !DYNAMIC_CACHE
|
||||
.lock()
|
||||
.expect("cache lock")
|
||||
.is_empty();
|
||||
assert!(
|
||||
populated,
|
||||
"resolve_dynamic should populate the memo cache",
|
||||
);
|
||||
let populated = !DYNAMIC_CACHE.lock().expect("cache lock").is_empty();
|
||||
assert!(populated, "resolve_dynamic should populate the memo cache",);
|
||||
}
|
||||
|
||||
// ---- ScopedSubgrammar (ADR-0032 §10.2) -----------------------
|
||||
@@ -1758,14 +1732,7 @@ mod tests {
|
||||
let mut path = MatchedPath::new();
|
||||
let mut per_byte = Vec::new();
|
||||
let baseline_frames = ctx.from_scope_stack.len();
|
||||
let result = walk_node(
|
||||
input,
|
||||
0,
|
||||
&SCOPED_NESTED,
|
||||
&mut ctx,
|
||||
&mut path,
|
||||
&mut per_byte,
|
||||
);
|
||||
let result = walk_node(input, 0, &SCOPED_NESTED, &mut ctx, &mut path, &mut per_byte);
|
||||
assert_eq!(
|
||||
ctx.subgrammar_depth, 0,
|
||||
"subgrammar_depth must be restored to 0 after the walk",
|
||||
@@ -1801,9 +1768,9 @@ mod tests {
|
||||
kind: FailureKind::Validation(err),
|
||||
..
|
||||
} => assert_eq!(err.message_key, "parse.custom.expression_too_deep"),
|
||||
other => panic!(
|
||||
"expected expression_too_deep on pathological scoped nesting, got {other:?}",
|
||||
),
|
||||
other => {
|
||||
panic!("expected expression_too_deep on pathological scoped nesting, got {other:?}",)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1822,9 +1789,7 @@ mod tests {
|
||||
/// Walk a top-level SQL SELECT and return the bottom frame's
|
||||
/// `from_scope` after the walk completes. Used to verify that
|
||||
/// `writes_table` / `writes_table_alias` populate bindings.
|
||||
fn from_scope_after_walk(
|
||||
input: &str,
|
||||
) -> Vec<crate::dsl::walker::context::TableBinding> {
|
||||
fn from_scope_after_walk(input: &str) -> Vec<crate::dsl::walker::context::TableBinding> {
|
||||
let mut ctx = WalkContext::new();
|
||||
let mut path = MatchedPath::new();
|
||||
let mut per_byte = Vec::new();
|
||||
@@ -1871,9 +1836,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn join_pushes_a_second_binding() {
|
||||
let bindings = from_scope_after_walk(
|
||||
"select * from a join b on x = y",
|
||||
);
|
||||
let bindings = from_scope_after_walk("select * from a join b on x = y");
|
||||
assert_eq!(bindings.len(), 2);
|
||||
assert_eq!(bindings[0].table, "a");
|
||||
assert_eq!(bindings[1].table, "b");
|
||||
@@ -1881,9 +1844,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn join_with_aliases() {
|
||||
let bindings = from_scope_after_walk(
|
||||
"select * from a as x join b as y on x.id = y.id",
|
||||
);
|
||||
let bindings = from_scope_after_walk("select * from a as x join b as y on x.id = y.id");
|
||||
assert_eq!(bindings.len(), 2);
|
||||
assert_eq!(bindings[0].table, "a");
|
||||
assert_eq!(bindings[0].alias, Some("x".to_string()));
|
||||
@@ -1893,9 +1854,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn three_way_join_pushes_three_bindings() {
|
||||
let bindings = from_scope_after_walk(
|
||||
"select * from a join b on x = y left join c on y = z",
|
||||
);
|
||||
let bindings =
|
||||
from_scope_after_walk("select * from a join b on x = y left join c on y = z");
|
||||
assert_eq!(bindings.len(), 3);
|
||||
assert_eq!(bindings[0].table, "a");
|
||||
assert_eq!(bindings[1].table, "b");
|
||||
@@ -1908,9 +1868,8 @@ mod tests {
|
||||
// binding into the inner scope frame; on exit, the frame
|
||||
// pops and the inner binding is gone. The outer scope's
|
||||
// from_scope still contains only `outer_t`.
|
||||
let bindings = from_scope_after_walk(
|
||||
"select * from outer_t where id in (select id from inner_t)",
|
||||
);
|
||||
let bindings =
|
||||
from_scope_after_walk("select * from outer_t where id in (select id from inner_t)");
|
||||
assert_eq!(bindings.len(), 1);
|
||||
assert_eq!(bindings[0].table, "outer_t");
|
||||
}
|
||||
@@ -1921,9 +1880,8 @@ mod tests {
|
||||
// body's scope frame; on body-frame exit, the inner
|
||||
// binding goes away. The outer scope contains only
|
||||
// the CTE-name reference `cte_x`.
|
||||
let bindings = from_scope_after_walk(
|
||||
"with cte_x as (select * from base_table) select * from cte_x",
|
||||
);
|
||||
let bindings =
|
||||
from_scope_after_walk("with cte_x as (select * from base_table) select * from cte_x");
|
||||
assert_eq!(bindings.len(), 1);
|
||||
assert_eq!(bindings[0].table, "cte_x");
|
||||
}
|
||||
@@ -1940,10 +1898,7 @@ mod tests {
|
||||
/// `cte_bindings` and `projection_aliases` after the walk.
|
||||
fn frame_state_after_walk(
|
||||
input: &str,
|
||||
) -> (
|
||||
Vec<crate::dsl::walker::context::CteBinding>,
|
||||
Vec<String>,
|
||||
) {
|
||||
) -> (Vec<crate::dsl::walker::context::CteBinding>, Vec<String>) {
|
||||
let mut ctx = WalkContext::new();
|
||||
let mut path = MatchedPath::new();
|
||||
let mut per_byte = Vec::new();
|
||||
@@ -1968,9 +1923,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cte_name_pushes_placeholder_binding() {
|
||||
let (ctes, _) = frame_state_after_walk(
|
||||
"with cte_x as (select 1) select * from cte_x",
|
||||
);
|
||||
let (ctes, _) = frame_state_after_walk("with cte_x as (select 1) select * from cte_x");
|
||||
assert_eq!(ctes.len(), 1);
|
||||
assert_eq!(ctes[0].name, "cte_x");
|
||||
// §10.3 stage-2 harvest produces one CteColumn per
|
||||
@@ -1984,9 +1937,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn multiple_ctes_push_in_order() {
|
||||
let (ctes, _) = frame_state_after_walk(
|
||||
"with a as (select 1), b as (select 2) select * from b",
|
||||
);
|
||||
let (ctes, _) =
|
||||
frame_state_after_walk("with a as (select 1), b as (select 2) select * from b");
|
||||
assert_eq!(ctes.len(), 2);
|
||||
assert_eq!(ctes[0].name, "a");
|
||||
assert_eq!(ctes[1].name, "b");
|
||||
@@ -2006,25 +1958,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn projection_aliases_captured_via_as_form() {
|
||||
let (_, aliases) = frame_state_after_walk(
|
||||
"select a as alpha, b as beta from t",
|
||||
);
|
||||
let (_, aliases) = frame_state_after_walk("select a as alpha, b as beta from t");
|
||||
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_aliases_captured_via_bare_form() {
|
||||
let (_, aliases) = frame_state_after_walk(
|
||||
"select a alpha, b beta from t",
|
||||
);
|
||||
let (_, aliases) = frame_state_after_walk("select a alpha, b beta from t");
|
||||
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_aliases_mixed_forms() {
|
||||
let (_, aliases) = frame_state_after_walk(
|
||||
"select a as alpha, b beta, c, d as delta from t",
|
||||
);
|
||||
let (_, aliases) =
|
||||
frame_state_after_walk("select a as alpha, b beta, c, d as delta from t");
|
||||
assert_eq!(
|
||||
aliases,
|
||||
vec!["alpha".to_string(), "beta".to_string(), "delta".to_string()]
|
||||
@@ -2033,8 +1980,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn projection_aliases_empty_when_no_aliases() {
|
||||
let (_, aliases) =
|
||||
frame_state_after_walk("select a, b from t");
|
||||
let (_, aliases) = frame_state_after_walk("select a, b from t");
|
||||
assert!(aliases.is_empty());
|
||||
}
|
||||
|
||||
@@ -2088,9 +2034,24 @@ mod tests {
|
||||
s.table_columns.insert(
|
||||
"users".to_string(),
|
||||
vec![
|
||||
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
|
||||
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
|
||||
TableColumn { name: "age".to_string(), user_type: Type::Int, not_null: false, has_default: false },
|
||||
TableColumn {
|
||||
name: "id".to_string(),
|
||||
user_type: Type::Int,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
},
|
||||
TableColumn {
|
||||
name: "name".to_string(),
|
||||
user_type: Type::Text,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
},
|
||||
TableColumn {
|
||||
name: "age".to_string(),
|
||||
user_type: Type::Int,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
s
|
||||
@@ -2108,10 +2069,7 @@ mod tests {
|
||||
assert_eq!(ctes.len(), 1);
|
||||
assert_eq!(ctes[0].columns.len(), 3);
|
||||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id"));
|
||||
assert_eq!(
|
||||
ctes[0].columns[0].type_,
|
||||
Some(crate::dsl::types::Type::Int),
|
||||
);
|
||||
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||||
assert_eq!(ctes[0].columns[1].name.as_deref(), Some("name"));
|
||||
assert_eq!(
|
||||
ctes[0].columns[1].type_,
|
||||
@@ -2161,10 +2119,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(ctes[0].columns.len(), 1);
|
||||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("age"));
|
||||
assert_eq!(
|
||||
ctes[0].columns[0].type_,
|
||||
Some(crate::dsl::types::Type::Int),
|
||||
);
|
||||
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2259,15 +2214,9 @@ mod tests {
|
||||
.expect("outer_cte binding");
|
||||
assert_eq!(outer.columns.len(), 2);
|
||||
assert_eq!(outer.columns[0].name.as_deref(), Some("id"));
|
||||
assert_eq!(
|
||||
outer.columns[0].type_,
|
||||
Some(crate::dsl::types::Type::Int),
|
||||
);
|
||||
assert_eq!(outer.columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||||
assert_eq!(outer.columns[1].name.as_deref(), Some("name"));
|
||||
assert_eq!(
|
||||
outer.columns[1].type_,
|
||||
Some(crate::dsl::types::Type::Text),
|
||||
);
|
||||
assert_eq!(outer.columns[1].type_, Some(crate::dsl::types::Type::Text),);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2287,15 +2236,9 @@ mod tests {
|
||||
let b = ctes.iter().find(|c| c.name == "b").expect("b binding");
|
||||
assert_eq!(b.columns.len(), 2);
|
||||
assert_eq!(b.columns[0].name.as_deref(), Some("id"));
|
||||
assert_eq!(
|
||||
b.columns[0].type_,
|
||||
Some(crate::dsl::types::Type::Int),
|
||||
);
|
||||
assert_eq!(b.columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||||
assert_eq!(b.columns[1].name.as_deref(), Some("name"));
|
||||
assert_eq!(
|
||||
b.columns[1].type_,
|
||||
Some(crate::dsl::types::Type::Text),
|
||||
);
|
||||
assert_eq!(b.columns[1].type_, Some(crate::dsl::types::Type::Text),);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2310,10 +2253,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(ctes[0].columns.len(), 3);
|
||||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("a"));
|
||||
assert_eq!(
|
||||
ctes[0].columns[0].type_,
|
||||
Some(crate::dsl::types::Type::Int),
|
||||
);
|
||||
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||||
assert_eq!(ctes[0].columns[1].name.as_deref(), Some("b"));
|
||||
assert_eq!(
|
||||
ctes[0].columns[1].type_,
|
||||
|
||||
+41
-28
@@ -24,8 +24,8 @@
|
||||
use crate::dsl::grammar::HighlightClass;
|
||||
use crate::dsl::walker::context::WalkContext;
|
||||
use crate::dsl::walker::lex_helpers::{
|
||||
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
|
||||
consume_string_literal, skip_whitespace,
|
||||
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
|
||||
skip_whitespace,
|
||||
};
|
||||
use crate::dsl::walker::outcome::{ByteClass, WalkBound};
|
||||
|
||||
@@ -47,16 +47,11 @@ pub fn highlight_runs(source: &str) -> Vec<ByteClass> {
|
||||
/// token, producing the keyword classes the renderer needs to
|
||||
/// colour `select` / `from` / `where` / `union` / `case` / etc.
|
||||
#[must_use]
|
||||
pub fn highlight_runs_in_mode(
|
||||
source: &str,
|
||||
mode: crate::mode::Mode,
|
||||
) -> Vec<ByteClass> {
|
||||
pub fn highlight_runs_in_mode(source: &str, mode: crate::mode::Mode) -> Vec<ByteClass> {
|
||||
let mut ctx = WalkContext::new();
|
||||
ctx.mode = mode;
|
||||
let (result, _cmd) = super::walk(source, WalkBound::EndOfInput, &mut ctx);
|
||||
let mut classes: Vec<ByteClass> = result
|
||||
.map(|r| r.per_byte_class)
|
||||
.unwrap_or_default();
|
||||
let mut classes: Vec<ByteClass> = result.map(|r| r.per_byte_class).unwrap_or_default();
|
||||
|
||||
let scan_start = classes.last().map_or(0, |c| c.end);
|
||||
scan_remainder(source, scan_start, &mut classes);
|
||||
@@ -133,9 +128,7 @@ fn scan_remainder(source: &str, start: usize, classes: &mut Vec<ByteClass>) {
|
||||
.get(pos + 1)
|
||||
.copied()
|
||||
.is_some_and(|c| c.is_ascii_digit()));
|
||||
if looks_like_number
|
||||
&& let Some((s, e)) = consume_number_literal(source, pos)
|
||||
{
|
||||
if looks_like_number && let Some((s, e)) = consume_number_literal(source, pos) {
|
||||
classes.push(ByteClass {
|
||||
start: s,
|
||||
end: e,
|
||||
@@ -222,8 +215,14 @@ mod tests {
|
||||
"no Error highlight on a valid m:n line: {runs:?}"
|
||||
);
|
||||
let kinds: Vec<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect();
|
||||
assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}");
|
||||
assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}");
|
||||
assert!(
|
||||
kinds.contains(&HighlightClass::Keyword),
|
||||
"keywords highlighted: {runs:?}"
|
||||
);
|
||||
assert!(
|
||||
kinds.contains(&HighlightClass::Identifier),
|
||||
"table names highlighted: {runs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -276,10 +275,7 @@ mod tests {
|
||||
#[test]
|
||||
fn flag_classified_via_fallback() {
|
||||
// Walker doesn't engage for a bare `--all-rows`.
|
||||
assert_eq!(
|
||||
run("--all-rows"),
|
||||
vec![(0, 10, HighlightClass::Flag)],
|
||||
);
|
||||
assert_eq!(run("--all-rows"), vec![(0, 10, HighlightClass::Flag)],);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -445,15 +441,13 @@ mod tests {
|
||||
// dispatcher, so only the entry word would highlight).
|
||||
let runs = run_advanced("select * from t");
|
||||
assert!(
|
||||
runs.iter().any(|(s, e, c)| {
|
||||
*c == HighlightClass::Keyword && (*s, *e) == (0, 6)
|
||||
}),
|
||||
runs.iter()
|
||||
.any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (0, 6) }),
|
||||
"expected `select` keyword span 0..6; got {runs:?}",
|
||||
);
|
||||
assert!(
|
||||
runs.iter().any(|(s, e, c)| {
|
||||
*c == HighlightClass::Keyword && (*s, *e) == (9, 13)
|
||||
}),
|
||||
runs.iter()
|
||||
.any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (9, 13) }),
|
||||
"expected `from` keyword span 9..13; got {runs:?}",
|
||||
);
|
||||
}
|
||||
@@ -514,18 +508,37 @@ mod tests {
|
||||
let insert = keywords_of(
|
||||
"insert into t (a) values (1) on conflict (a) do update set a = excluded.a returning a",
|
||||
);
|
||||
for kw in ["insert", "into", "values", "on", "conflict", "do", "update", "set", "returning"] {
|
||||
assert!(insert.contains(&kw), "INSERT/UPSERT: missing `{kw}`; got {insert:?}");
|
||||
for kw in [
|
||||
"insert",
|
||||
"into",
|
||||
"values",
|
||||
"on",
|
||||
"conflict",
|
||||
"do",
|
||||
"update",
|
||||
"set",
|
||||
"returning",
|
||||
] {
|
||||
assert!(
|
||||
insert.contains(&kw),
|
||||
"INSERT/UPSERT: missing `{kw}`; got {insert:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let update = keywords_of("update t set a = 1 where id = 2 returning a");
|
||||
for kw in ["update", "set", "where", "returning"] {
|
||||
assert!(update.contains(&kw), "UPDATE: missing `{kw}`; got {update:?}");
|
||||
assert!(
|
||||
update.contains(&kw),
|
||||
"UPDATE: missing `{kw}`; got {update:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let delete = keywords_of("delete from t where id = 1 returning *");
|
||||
for kw in ["delete", "from", "where", "returning"] {
|
||||
assert!(delete.contains(&kw), "DELETE: missing `{kw}`; got {delete:?}");
|
||||
assert!(
|
||||
delete.contains(&kw),
|
||||
"DELETE: missing `{kw}`; got {delete:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,9 +110,7 @@ pub fn consume_number_literal(source: &str, start: usize) -> Option<(usize, usiz
|
||||
return None;
|
||||
}
|
||||
let mut i = start;
|
||||
let leading_minus = bytes[i] == b'-'
|
||||
&& i + 1 < bytes.len()
|
||||
&& bytes[i + 1].is_ascii_digit();
|
||||
let leading_minus = bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit();
|
||||
if leading_minus {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
+407
-536
File diff suppressed because it is too large
Load Diff
+110
-38
@@ -14,12 +14,12 @@
|
||||
//! advanced effective mode (ADR-0037).
|
||||
|
||||
use crate::app::EffectiveMode;
|
||||
use crate::dsl::ReferentialAction;
|
||||
use crate::dsl::types::Type;
|
||||
use crate::dsl::Command;
|
||||
use crate::dsl::ReferentialAction;
|
||||
use crate::dsl::command::{
|
||||
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
|
||||
};
|
||||
use crate::dsl::types::Type;
|
||||
use crate::dsl::value::Value;
|
||||
|
||||
/// The dimmed `Executing SQL:` prefix on a teaching-echo line
|
||||
@@ -79,7 +79,12 @@ pub fn echo_for_query(
|
||||
name,
|
||||
filter,
|
||||
limit,
|
||||
} => Some(vec![render_show_data(name, filter.as_ref(), *limit, primary_key)]),
|
||||
} => Some(vec![render_show_data(
|
||||
name,
|
||||
filter.as_ref(),
|
||||
*limit,
|
||||
primary_key,
|
||||
)]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -150,12 +155,12 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
|
||||
column,
|
||||
kind,
|
||||
} => match kind {
|
||||
ConstraintKind::NotNull => {
|
||||
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"))
|
||||
}
|
||||
ConstraintKind::Default => {
|
||||
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"))
|
||||
}
|
||||
ConstraintKind::NotNull => Some(format!(
|
||||
"ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"
|
||||
)),
|
||||
ConstraintKind::Default => Some(format!(
|
||||
"ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"
|
||||
)),
|
||||
// A column-level UNIQUE / CHECK is anonymous in our model —
|
||||
// no portable name to DROP CONSTRAINT by, so no echo (Bucket C,
|
||||
// ADR-0035 Amendment 2 residual gap / ADR-0038 §7).
|
||||
@@ -169,7 +174,10 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
|
||||
table,
|
||||
assignments,
|
||||
filter: RowFilter::AllRows,
|
||||
} => Some(format!("UPDATE {table} SET {}", render_assignments(assignments))),
|
||||
} => Some(format!(
|
||||
"UPDATE {table} SET {}",
|
||||
render_assignments(assignments)
|
||||
)),
|
||||
Command::Delete {
|
||||
table,
|
||||
filter: RowFilter::AllRows,
|
||||
@@ -199,7 +207,13 @@ fn render_create_table(name: &str, columns: &[ColumnSpec], primary_key: &[String
|
||||
// The same column-constraint suffix `add column` emits (ADR-0029):
|
||||
// simple-mode `create table` can carry `default` / `check` too, so
|
||||
// the echo must render them or it is not equivalent (§1 contract).
|
||||
append_constraints(&mut s, c.not_null, c.unique, c.default.as_ref(), c.check.as_ref());
|
||||
append_constraints(
|
||||
&mut s,
|
||||
c.not_null,
|
||||
c.unique,
|
||||
c.default.as_ref(),
|
||||
c.check.as_ref(),
|
||||
);
|
||||
s
|
||||
})
|
||||
.collect();
|
||||
@@ -299,8 +313,10 @@ pub(crate) fn render_create_m2n(
|
||||
primary_key: &[String],
|
||||
foreign_keys: &[(Vec<String>, String, Vec<String>)],
|
||||
) -> String {
|
||||
let mut parts: Vec<String> =
|
||||
columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect();
|
||||
let mut parts: Vec<String> = columns
|
||||
.iter()
|
||||
.map(|(n, ty)| format!("{n} {}", ty.keyword()))
|
||||
.collect();
|
||||
parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
|
||||
for (child_columns, parent_table, parent_columns) in foreign_keys {
|
||||
parts.push(format!(
|
||||
@@ -368,7 +384,12 @@ pub(crate) fn render_add_relationship_create_fk(
|
||||
) -> Vec<String> {
|
||||
let mut lines: Vec<String> = new_columns
|
||||
.iter()
|
||||
.map(|(col, ty)| format!("ALTER TABLE {child_table} ADD COLUMN {col} {}", ty.keyword()))
|
||||
.map(|(col, ty)| {
|
||||
format!(
|
||||
"ALTER TABLE {child_table} ADD COLUMN {col} {}",
|
||||
ty.keyword()
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
lines.push(render_add_relationship(
|
||||
name,
|
||||
@@ -461,7 +482,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
|
||||
negated,
|
||||
} => {
|
||||
let not = if *negated { "NOT " } else { "" };
|
||||
format!("{} {not}LIKE {}", operand_to_sql(target), operand_to_sql(pattern))
|
||||
format!(
|
||||
"{} {not}LIKE {}",
|
||||
operand_to_sql(target),
|
||||
operand_to_sql(pattern)
|
||||
)
|
||||
}
|
||||
Predicate::Between {
|
||||
target,
|
||||
@@ -484,7 +509,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
|
||||
} => {
|
||||
let not = if *negated { "NOT " } else { "" };
|
||||
let rendered: Vec<String> = items.iter().map(operand_to_sql).collect();
|
||||
format!("{} {not}IN ({})", operand_to_sql(target), rendered.join(", "))
|
||||
format!(
|
||||
"{} {not}IN ({})",
|
||||
operand_to_sql(target),
|
||||
rendered.join(", ")
|
||||
)
|
||||
}
|
||||
Predicate::IsNull { target, negated } => {
|
||||
let not = if *negated { "NOT " } else { "" };
|
||||
@@ -562,7 +591,10 @@ mod tests {
|
||||
fn create_table_compound_pk_renders_table_level() {
|
||||
let cmd = create_table(
|
||||
"T",
|
||||
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
|
||||
vec![
|
||||
ColumnSpec::new("a", Type::Int),
|
||||
ColumnSpec::new("b", Type::Int),
|
||||
],
|
||||
&["a", "b"],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -594,7 +626,11 @@ mod tests {
|
||||
default: Some(Value::Text("A".to_string())),
|
||||
..ColumnSpec::new("grade", Type::Text)
|
||||
};
|
||||
let cmd = create_table("T", vec![ColumnSpec::new("id", Type::Serial), age, grade], &["id"]);
|
||||
let cmd = create_table(
|
||||
"T",
|
||||
vec![ColumnSpec::new("id", Type::Serial), age, grade],
|
||||
&["id"],
|
||||
);
|
||||
let sql = command_to_sql(&cmd).expect("echo");
|
||||
assert_eq!(
|
||||
sql,
|
||||
@@ -625,11 +661,11 @@ mod tests {
|
||||
check: None,
|
||||
};
|
||||
let sql = command_to_sql(&cmd).expect("echo");
|
||||
assert_eq!(sql, "ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'");
|
||||
assert!(matches!(
|
||||
reparse(&sql),
|
||||
Ok(Command::SqlAlterTable { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
sql,
|
||||
"ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'"
|
||||
);
|
||||
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -657,7 +693,10 @@ mod tests {
|
||||
})),
|
||||
};
|
||||
let sql = command_to_sql(&cmd).expect("echo");
|
||||
assert_eq!(sql, "ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)");
|
||||
assert_eq!(
|
||||
sql,
|
||||
"ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)"
|
||||
);
|
||||
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
|
||||
}
|
||||
|
||||
@@ -1031,7 +1070,10 @@ mod tests {
|
||||
let lines = render_drop_column_cascade(
|
||||
"Orders",
|
||||
"CustId",
|
||||
&["Orders_CustId_idx".to_string(), "Orders_CustId_Day_idx".to_string()],
|
||||
&[
|
||||
"Orders_CustId_idx".to_string(),
|
||||
"Orders_CustId_Day_idx".to_string(),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
lines.as_slice(),
|
||||
@@ -1043,9 +1085,18 @@ mod tests {
|
||||
);
|
||||
// Each line is itself runnable advanced-mode SQL (the §1 contract
|
||||
// holds per line for category 2).
|
||||
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlDropIndex { .. })));
|
||||
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlDropIndex { .. })));
|
||||
assert!(matches!(reparse(&lines[2]), Ok(Command::SqlAlterTable { .. })));
|
||||
assert!(matches!(
|
||||
reparse(&lines[0]),
|
||||
Ok(Command::SqlDropIndex { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
reparse(&lines[1]),
|
||||
Ok(Command::SqlDropIndex { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
reparse(&lines[2]),
|
||||
Ok(Command::SqlAlterTable { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1054,7 +1105,10 @@ mod tests {
|
||||
// plain `DROP COLUMN` — still semantically equivalent.
|
||||
let lines = render_drop_column_cascade("T", "c", &[]);
|
||||
assert_eq!(lines.as_slice(), &["ALTER TABLE T DROP COLUMN c"]);
|
||||
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. })));
|
||||
assert!(matches!(
|
||||
reparse(&lines[0]),
|
||||
Ok(Command::SqlAlterTable { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1078,8 +1132,14 @@ mod tests {
|
||||
"ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE",
|
||||
]
|
||||
);
|
||||
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. })));
|
||||
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlAlterTable { .. })));
|
||||
assert!(matches!(
|
||||
reparse(&lines[0]),
|
||||
Ok(Command::SqlAlterTable { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
reparse(&lines[1]),
|
||||
Ok(Command::SqlAlterTable { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1116,8 +1176,16 @@ mod tests {
|
||||
],
|
||||
&["Students_id".to_string(), "Courses_id".to_string()],
|
||||
&[
|
||||
(vec!["Students_id".to_string()], "Students".to_string(), vec!["id".to_string()]),
|
||||
(vec!["Courses_id".to_string()], "Courses".to_string(), vec!["id".to_string()]),
|
||||
(
|
||||
vec!["Students_id".to_string()],
|
||||
"Students".to_string(),
|
||||
vec!["id".to_string()],
|
||||
),
|
||||
(
|
||||
vec!["Courses_id".to_string()],
|
||||
"Courses".to_string(),
|
||||
vec!["id".to_string()],
|
||||
),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1172,8 +1240,14 @@ mod tests {
|
||||
#[test]
|
||||
fn value_literal_renders_null_uppercase_and_quotes_text() {
|
||||
assert_eq!(value_to_sql_literal(&Value::Null), "NULL");
|
||||
assert_eq!(value_to_sql_literal(&Value::Text("O'Hara".to_string())), "'O''Hara'");
|
||||
assert_eq!(value_to_sql_literal(&Value::Number("3.14".to_string())), "3.14");
|
||||
assert_eq!(
|
||||
value_to_sql_literal(&Value::Text("O'Hara".to_string())),
|
||||
"'O''Hara'"
|
||||
);
|
||||
assert_eq!(
|
||||
value_to_sql_literal(&Value::Number("3.14".to_string())),
|
||||
"3.14"
|
||||
);
|
||||
assert_eq!(value_to_sql_literal(&Value::Bool(false)), "false");
|
||||
}
|
||||
|
||||
@@ -1258,9 +1332,7 @@ mod tests {
|
||||
"Command::App({app:?}) is Bucket C — no echo"
|
||||
);
|
||||
// Also confirm echo_for gates the same in advanced mode.
|
||||
assert!(
|
||||
echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),
|
||||
);
|
||||
assert!(echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-5
@@ -8,9 +8,8 @@
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
use crate::db::{
|
||||
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
|
||||
DropColumnResult, InsertResult, QueryPlan, RelationshipDiagramData, TableDescription,
|
||||
UpdateResult,
|
||||
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, DropColumnResult,
|
||||
InsertResult, QueryPlan, RelationshipDiagramData, TableDescription, UpdateResult,
|
||||
};
|
||||
use crate::dsl::Command;
|
||||
|
||||
@@ -73,10 +72,16 @@ pub enum AppEvent {
|
||||
},
|
||||
/// An `explain …` command succeeded (ADR-0028). `plan`
|
||||
/// carries the captured query plan; nothing was executed.
|
||||
DslExplainSucceeded { command: Command, plan: QueryPlan },
|
||||
DslExplainSucceeded {
|
||||
command: Command,
|
||||
plan: QueryPlan,
|
||||
},
|
||||
/// A `show <kind>` list command (V5) — carries pre-formatted
|
||||
/// display lines (tables / relationships / indexes).
|
||||
DslShowListSucceeded { command: Command, lines: Vec<String> },
|
||||
DslShowListSucceeded {
|
||||
command: Command,
|
||||
lines: Vec<String>,
|
||||
},
|
||||
/// `show relationship <name>` (ADR-0044) — structured data for the
|
||||
/// diagram, rendered App-side; `None` when no such relationship.
|
||||
DslShowRelationshipSucceeded {
|
||||
|
||||
+3
-11
@@ -43,17 +43,11 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten(
|
||||
value: &serde_norway::Value,
|
||||
prefix: String,
|
||||
out: &mut HashMap<String, String>,
|
||||
) {
|
||||
fn flatten(value: &serde_norway::Value, prefix: String, out: &mut HashMap<String, String>) {
|
||||
match value {
|
||||
serde_norway::Value::Mapping(map) => {
|
||||
for (k, v) in map {
|
||||
let k_str = k
|
||||
.as_str()
|
||||
.expect("catalog keys must be strings");
|
||||
let k_str = k.as_str().expect("catalog keys must be strings");
|
||||
let next = if prefix.is_empty() {
|
||||
k_str.to_string()
|
||||
} else {
|
||||
@@ -85,9 +79,7 @@ pub fn catalog() -> &'static Catalog {
|
||||
/// See module docs for failure modes.
|
||||
pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String {
|
||||
let template = catalog().get(key).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"missing catalog key: `{key}` (the validator should have caught this)"
|
||||
);
|
||||
panic!("missing catalog key: `{key}` (the validator should have caught this)");
|
||||
});
|
||||
substitute(template, args, key)
|
||||
}
|
||||
|
||||
+29
-29
@@ -41,8 +41,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("diagnostic.alias_used_as_column", &["name"]),
|
||||
("diagnostic.ambiguous_column", &["column", "qualifiers"]),
|
||||
("diagnostic.auto_column_overridden", &["column", "type"]),
|
||||
("diagnostic.compound_arity_mismatch", &["op", "left_n", "right_n"]),
|
||||
("diagnostic.cte_arity_mismatch", &["cte", "declared", "actual"]),
|
||||
(
|
||||
"diagnostic.compound_arity_mismatch",
|
||||
&["op", "left_n", "right_n"],
|
||||
),
|
||||
(
|
||||
"diagnostic.cte_arity_mismatch",
|
||||
&["cte", "declared", "actual"],
|
||||
),
|
||||
("diagnostic.duplicate_cte", &["name"]),
|
||||
("diagnostic.eq_null", &[]),
|
||||
("diagnostic.insert_arity_mismatch", &["expected", "actual"]),
|
||||
@@ -63,7 +69,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
),
|
||||
("diagnostic.not_null_missing", &["column"]),
|
||||
("diagnostic.like_numeric", &["column", "type"]),
|
||||
("diagnostic.projection_alias_misplaced", &["alias", "clause"]),
|
||||
(
|
||||
"diagnostic.projection_alias_misplaced",
|
||||
&["alias", "clause"],
|
||||
),
|
||||
("diagnostic.table_used_as_column", &["name"]),
|
||||
("diagnostic.type_mismatch", &["column", "type"]),
|
||||
("diagnostic.unknown_column", &["name", "table"]),
|
||||
@@ -149,10 +158,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
"error.type_mismatch.change_column.headline",
|
||||
&["table", "column", "src_type", "target_type"],
|
||||
),
|
||||
(
|
||||
"error.type_mismatch.change_column.hint",
|
||||
&["target_type"],
|
||||
),
|
||||
("error.type_mismatch.change_column.hint", &["target_type"]),
|
||||
(
|
||||
"error.type_mismatch.insert.headline",
|
||||
&["value", "expected_type"],
|
||||
@@ -181,6 +187,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("help.app.quit", &[]),
|
||||
("help.app.help", &[]),
|
||||
("help.app.hint", &[]),
|
||||
("help.app.version", &[]),
|
||||
("help.app.rebuild", &[]),
|
||||
("help.app.save", &[]),
|
||||
("help.app.new", &[]),
|
||||
@@ -218,10 +225,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("help.data.explain", &[]),
|
||||
// ---- Hint panel ambient typing assistance (ADR-0022 §6) ----
|
||||
("hint.ambient_complete", &[]),
|
||||
(
|
||||
"hint.ambient_error_with_usage",
|
||||
&["message", "usage"],
|
||||
),
|
||||
("hint.ambient_error_with_usage", &["message", "usage"]),
|
||||
("hint.ambient_expected", &["expected"]),
|
||||
("hint.getting_started", &[]),
|
||||
("hint.block.heading", &[]),
|
||||
@@ -272,6 +276,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("hint.cmd.help.concept", &[]),
|
||||
("hint.cmd.hint.what", &[]),
|
||||
("hint.cmd.hint.example", &[]),
|
||||
("hint.cmd.version.what", &[]),
|
||||
("hint.cmd.version.example", &[]),
|
||||
("hint.cmd.rebuild.what", &[]),
|
||||
("hint.cmd.rebuild.example", &[]),
|
||||
("hint.cmd.rebuild.concept", &[]),
|
||||
@@ -401,10 +407,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("hint.cmd.explain_sql.what", &[]),
|
||||
("hint.cmd.explain_sql.example", &[]),
|
||||
("hint.cmd.explain_sql.concept", &[]),
|
||||
(
|
||||
"hint.ambient_invalid_ident",
|
||||
&["kind", "found"],
|
||||
),
|
||||
("hint.ambient_invalid_ident", &["kind", "found"]),
|
||||
("hint.ambient_typing_name", &[]),
|
||||
// Issue #4: introduce the advanced-mode CREATE TABLE element
|
||||
// slot (`create table T (`) so the otherwise-invisible
|
||||
@@ -412,10 +415,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("hint.create_table_element", &[]),
|
||||
("hint.seed_count", &[]),
|
||||
("hint.value_literal_slot", &[]),
|
||||
(
|
||||
"hint.ambient_typing_name_then",
|
||||
&["next"],
|
||||
),
|
||||
("hint.ambient_typing_name_then", &["next"]),
|
||||
// Per-column-type value-slot hints (ADR-0024 §Phase D).
|
||||
("hint.value_slot_blob", &[]),
|
||||
("hint.value_slot_bool", &[]),
|
||||
@@ -438,7 +438,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("parse.custom.alter_named_unique", &[]),
|
||||
("parse.custom.bind_type_mismatch", &["found", "expected"]),
|
||||
("parse.custom.change_column_flags_exclusive", &[]),
|
||||
("parse.custom.constraint_redundant_on_pk", &["column", "constraint"]),
|
||||
(
|
||||
"parse.custom.constraint_redundant_on_pk",
|
||||
&["column", "constraint"],
|
||||
),
|
||||
("parse.custom.create_table_needs_pk", &[]),
|
||||
("parse.custom.expression_too_deep", &[]),
|
||||
("parse.custom.insert_form_a_missing_values", &["columns"]),
|
||||
@@ -486,6 +489,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("parse.usage.mode", &[]),
|
||||
("parse.usage.new", &[]),
|
||||
("parse.usage.quit", &[]),
|
||||
("parse.usage.version", &[]),
|
||||
("parse.usage.rebuild", &[]),
|
||||
("parse.usage.redo", &[]),
|
||||
("parse.usage.replay", &[]),
|
||||
@@ -572,14 +576,12 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
&["table", "col_count", "col_list", "supplied", "non_auto_csv"],
|
||||
),
|
||||
("select.internal_table", &["table"]),
|
||||
(
|
||||
"cli.invalid_value",
|
||||
&["flag", "value", "expected"],
|
||||
),
|
||||
("cli.invalid_value", &["flag", "value", "expected"]),
|
||||
("cli.missing_value", &["flag"]),
|
||||
("cli.multiple_paths", &["first", "second"]),
|
||||
("cli.resume_with_path", &[]),
|
||||
("cli.unknown_argument", &["arg"]),
|
||||
("cli.version_line", &["version"]),
|
||||
(
|
||||
"archive.export_sequence_exhausted",
|
||||
&["project", "target_dir", "limit"],
|
||||
@@ -862,8 +864,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
let declared: HashSet<&str> =
|
||||
KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
|
||||
let declared: HashSet<&str> = KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
|
||||
for key in cat.keys() {
|
||||
if key.starts_with("_test.") {
|
||||
continue;
|
||||
@@ -885,9 +886,8 @@ mod tests {
|
||||
/// Mirror of `tests/engine_vocabulary_audit.rs::FORBIDDEN`,
|
||||
/// duplicated here so the catalog validator is self-contained
|
||||
/// (no dependency on the integration-test binary).
|
||||
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] = &[
|
||||
"SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA",
|
||||
];
|
||||
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] =
|
||||
&["SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA"];
|
||||
|
||||
/// Detect a `{name:...}` format-specifier placeholder.
|
||||
/// Doubled braces `{{` / `}}` are escapes — must skip them.
|
||||
|
||||
+2
-2
@@ -34,8 +34,8 @@ pub mod keys;
|
||||
pub mod translate;
|
||||
|
||||
pub use error::{DiagnosticTable, FriendlyError};
|
||||
pub use format::{catalog, Catalog};
|
||||
pub use translate::{error_hint_class, FailureContext, Operation, TranslateContext, Verbosity};
|
||||
pub use format::{Catalog, catalog};
|
||||
pub use translate::{FailureContext, Operation, TranslateContext, Verbosity, error_hint_class};
|
||||
|
||||
// `translate::translate` and `format::translate` are different
|
||||
// callables — the former is the structured DbError → FriendlyError
|
||||
|
||||
@@ -162,6 +162,10 @@ error:
|
||||
# ---- Help text (CLI banner + in-app `help` command) ------------------
|
||||
# ---- CLI argument-parsing errors (stderr before TUI starts) ---------
|
||||
cli:
|
||||
# Version line for `--version` / `-V` and the in-app `version` command
|
||||
# (ADR-0054). `{version}` is `CARGO_PKG_VERSION` — the single source of
|
||||
# truth, equal to the `v*` release tag (release CI guards the match).
|
||||
version_line: "rdbms-playground {version}"
|
||||
missing_value: "missing value for --{flag}"
|
||||
invalid_value: "invalid value for --{flag}: {value} (expected one of: {expected})"
|
||||
unknown_argument: "unknown argument: {arg}"
|
||||
@@ -186,6 +190,7 @@ help:
|
||||
|
||||
Options:
|
||||
-h, --help Print this help and exit.
|
||||
-V, --version Print the version and exit.
|
||||
--theme <light|dark> Override theme (default: auto-detect).
|
||||
--data-dir <PATH> Use PATH as the data root instead of
|
||||
the OS-standard location for this run.
|
||||
@@ -210,6 +215,7 @@ help:
|
||||
|
||||
App-level commands (typed inside the app, available in both modes):
|
||||
quit Exit cleanly.
|
||||
version Print the application version.
|
||||
mode simple|advanced Switch input mode.
|
||||
help Show this list of commands in-app.
|
||||
save Save the current temp project under a
|
||||
@@ -258,6 +264,8 @@ help:
|
||||
help <command> — detailed help for one command (e.g. `help insert`)
|
||||
hint: |-
|
||||
hint — explain the most recent error (press F1 for a hint on what you're typing)
|
||||
version: |-
|
||||
version — print the application version (same as the `--version` command-line flag)
|
||||
rebuild: |-
|
||||
rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
|
||||
save: |-
|
||||
@@ -425,6 +433,9 @@ hint:
|
||||
hint:
|
||||
what: "Explain the most recent error — or, pressing F1 while typing, the command you're building."
|
||||
example: "hint"
|
||||
version:
|
||||
what: "Print the application version."
|
||||
example: "version"
|
||||
rebuild:
|
||||
what: "Rebuild the project database from its saved text files."
|
||||
example: "rebuild"
|
||||
@@ -874,6 +885,7 @@ parse:
|
||||
quit: "quit"
|
||||
help: "help [<command>]"
|
||||
hint: "hint"
|
||||
version: "version"
|
||||
rebuild: "rebuild"
|
||||
save: "save | save as"
|
||||
new: "new"
|
||||
|
||||
+79
-76
@@ -201,11 +201,7 @@ impl TranslateContext {
|
||||
/// Combine schema-resolved facts with operation and
|
||||
/// verbosity to build the full translator input.
|
||||
#[must_use]
|
||||
pub fn from_facts(
|
||||
operation: Operation,
|
||||
verbosity: Verbosity,
|
||||
facts: FailureContext,
|
||||
) -> Self {
|
||||
pub fn from_facts(operation: Operation, verbosity: Verbosity, facts: FailureContext) -> Self {
|
||||
Self {
|
||||
operation: Some(operation),
|
||||
table: facts.table,
|
||||
@@ -234,15 +230,15 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
|
||||
// refusal sites). Catalog entries exist for the typed
|
||||
// invalid-value cases but the migration sweep
|
||||
// (ADR-0019 §9) is what wires them. For now, passthrough.
|
||||
DbError::Unsupported(message) | DbError::InvalidValue(message) => {
|
||||
passthrough(message)
|
||||
}
|
||||
DbError::Unsupported(message) | DbError::InvalidValue(message) => passthrough(message),
|
||||
DbError::PersistenceFatal { message, .. }
|
||||
| DbError::RebuildRowFailed { detail: message, .. }
|
||||
| DbError::RebuildRowFailed {
|
||||
detail: message, ..
|
||||
}
|
||||
| DbError::Io(message) => passthrough(message),
|
||||
DbError::WorkerGone => passthrough(
|
||||
"the database worker is no longer available — the application must restart",
|
||||
),
|
||||
DbError::WorkerGone => {
|
||||
passthrough("the database worker is no longer available — the application must restart")
|
||||
}
|
||||
};
|
||||
// Attach the row pinpoint when the runtime resolved one.
|
||||
// The translator never builds the table itself — it only
|
||||
@@ -320,11 +316,7 @@ const fn fk_hint_class(ctx: &TranslateContext) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_sqlite(
|
||||
message: &str,
|
||||
kind: SqliteErrorKind,
|
||||
ctx: &TranslateContext,
|
||||
) -> FriendlyError {
|
||||
fn translate_sqlite(message: &str, kind: SqliteErrorKind, ctx: &TranslateContext) -> FriendlyError {
|
||||
// `change column ... --dont-convert` lets the engine
|
||||
// accept or refuse each cell. Whatever the engine returns
|
||||
// (constraint, datatype mismatch, …) means "the new type
|
||||
@@ -392,8 +384,8 @@ fn translate_constraint(message: &str, ctx: &TranslateContext) -> FriendlyError
|
||||
// ---- UNIQUE -----------------------------------------------------
|
||||
|
||||
fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
|
||||
let (table, column) = parse_qualified_target(message)
|
||||
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
|
||||
let (table, column) =
|
||||
parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
|
||||
let value = ctx_value(ctx);
|
||||
match ctx.operation {
|
||||
Some(Operation::Update) => fe(
|
||||
@@ -405,11 +397,7 @@ fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
|
||||
),
|
||||
verbose_hint(
|
||||
ctx,
|
||||
t!(
|
||||
"error.unique.update.hint",
|
||||
table = table,
|
||||
column = column
|
||||
),
|
||||
t!("error.unique.update.hint", table = table, column = column),
|
||||
),
|
||||
),
|
||||
// Default to the INSERT variant — it's the most common
|
||||
@@ -425,11 +413,7 @@ fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
|
||||
),
|
||||
verbose_hint(
|
||||
ctx,
|
||||
t!(
|
||||
"error.unique.insert.hint",
|
||||
table = table,
|
||||
column = column
|
||||
),
|
||||
t!("error.unique.insert.hint", table = table, column = column),
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -542,8 +526,8 @@ fn fk_parent_side_update(ctx: &TranslateContext) -> FriendlyError {
|
||||
// ---- NOT NULL --------------------------------------------------
|
||||
|
||||
fn translate_not_null(message: &str, ctx: &TranslateContext) -> FriendlyError {
|
||||
let (table, column) = parse_qualified_target(message)
|
||||
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
|
||||
let (table, column) =
|
||||
parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
|
||||
match ctx.operation {
|
||||
Some(Operation::Update) => fe(
|
||||
t!(
|
||||
@@ -576,9 +560,17 @@ fn translate_check(_message: &str, ctx: &TranslateContext) -> FriendlyError {
|
||||
let column = ctx_column(ctx);
|
||||
let is_update = matches!(ctx.operation, Some(Operation::Update));
|
||||
let headline = if is_update {
|
||||
t!("error.check.update.headline", table = table, column = column)
|
||||
t!(
|
||||
"error.check.update.headline",
|
||||
table = table,
|
||||
column = column
|
||||
)
|
||||
} else {
|
||||
t!("error.check.insert.headline", table = table, column = column)
|
||||
t!(
|
||||
"error.check.insert.headline",
|
||||
table = table,
|
||||
column = column
|
||||
)
|
||||
};
|
||||
let hint = ctx.check_rule.as_ref().map_or_else(
|
||||
|| {
|
||||
@@ -613,8 +605,7 @@ fn translate_check(_message: &str, ctx: &TranslateContext) -> FriendlyError {
|
||||
// ---- not_found / already_exists --------------------------------
|
||||
|
||||
fn translate_not_found_table(message: &str, ctx: &TranslateContext) -> FriendlyError {
|
||||
let name = parse_after_colon(message)
|
||||
.map_or_else(|| ctx_table(ctx), str::to_string);
|
||||
let name = parse_after_colon(message).map_or_else(|| ctx_table(ctx), str::to_string);
|
||||
headline_only(t!("error.not_found.table.headline", name = name))
|
||||
}
|
||||
|
||||
@@ -656,17 +647,11 @@ fn translate_already_exists(message: &str, ctx: &TranslateContext) -> FriendlyEr
|
||||
column = column
|
||||
));
|
||||
}
|
||||
return headline_only(t!(
|
||||
"error.already_exists.table.headline",
|
||||
name = name
|
||||
));
|
||||
return headline_only(t!("error.already_exists.table.headline", name = name));
|
||||
}
|
||||
// No backticks — engine-style "table T already exists".
|
||||
if let Some(name) = parse_after_word(message, "table") {
|
||||
return headline_only(t!(
|
||||
"error.already_exists.table.headline",
|
||||
name = name
|
||||
));
|
||||
return headline_only(t!("error.already_exists.table.headline", name = name));
|
||||
}
|
||||
if let Some(name) = parse_after_word(message, "relationship") {
|
||||
return headline_only(t!(
|
||||
@@ -696,36 +681,25 @@ fn translate_generic(message: &str, ctx: &TranslateContext) -> FriendlyError {
|
||||
if lower.contains("misuse of aggregate") {
|
||||
return headline_only(t!("engine.aggregate_misuse", name = "?"));
|
||||
}
|
||||
if lower.contains("group by")
|
||||
|| lower.contains("must appear in")
|
||||
{
|
||||
if lower.contains("group by") || lower.contains("must appear in") {
|
||||
return headline_only(t!("engine.group_by_required"));
|
||||
}
|
||||
if (lower.contains("union")
|
||||
|| lower.contains("intersect")
|
||||
|| lower.contains("except"))
|
||||
if (lower.contains("union") || lower.contains("intersect") || lower.contains("except"))
|
||||
&& lower.contains("result columns")
|
||||
{
|
||||
// Last-resort safety net — the pre-flight pass in 2d.1
|
||||
// catches this in most cases; if the engine surfaces it
|
||||
// anyway, route it through the engine-neutral key.
|
||||
return headline_only(t!(
|
||||
"engine.compound_arity_mismatch",
|
||||
op = "set operator"
|
||||
));
|
||||
return headline_only(t!("engine.compound_arity_mismatch", op = "set operator"));
|
||||
}
|
||||
if lower.contains("scalar subquery") || lower.contains("more than one row") {
|
||||
return headline_only(t!("engine.scalar_subquery_too_many_rows"));
|
||||
}
|
||||
if lower.contains("recursive")
|
||||
&& (lower.contains("cte") || lower.contains("union"))
|
||||
{
|
||||
if lower.contains("recursive") && (lower.contains("cte") || lower.contains("union")) {
|
||||
return headline_only(t!("engine.recursive_cte_malformed"));
|
||||
}
|
||||
|
||||
let operation = ctx
|
||||
.operation
|
||||
.map_or("operation", Operation::keyword);
|
||||
let operation = ctx.operation.map_or("operation", Operation::keyword);
|
||||
// F2 (ADR-0035 Amendment 1): when no table is in context, use the
|
||||
// table-less hint so a contextless `friendly_message()` (replay, undo,
|
||||
// rebuild, export) never renders a literal `{table}` placeholder.
|
||||
@@ -789,23 +763,33 @@ fn ctx_table(ctx: &TranslateContext) -> String {
|
||||
}
|
||||
|
||||
fn ctx_column(ctx: &TranslateContext) -> String {
|
||||
ctx.column.clone().unwrap_or_else(|| "the column".to_string())
|
||||
ctx.column
|
||||
.clone()
|
||||
.unwrap_or_else(|| "the column".to_string())
|
||||
}
|
||||
|
||||
fn ctx_value(ctx: &TranslateContext) -> String {
|
||||
ctx.value.clone().unwrap_or_else(|| "that value".to_string())
|
||||
ctx.value
|
||||
.clone()
|
||||
.unwrap_or_else(|| "that value".to_string())
|
||||
}
|
||||
|
||||
fn ctx_parent_table(ctx: &TranslateContext) -> String {
|
||||
ctx.parent_table.clone().unwrap_or_else(|| "the referenced table".to_string())
|
||||
ctx.parent_table
|
||||
.clone()
|
||||
.unwrap_or_else(|| "the referenced table".to_string())
|
||||
}
|
||||
|
||||
fn ctx_parent_column(ctx: &TranslateContext) -> String {
|
||||
ctx.parent_column.clone().unwrap_or_else(|| "the referenced column".to_string())
|
||||
ctx.parent_column
|
||||
.clone()
|
||||
.unwrap_or_else(|| "the referenced column".to_string())
|
||||
}
|
||||
|
||||
fn ctx_child_table(ctx: &TranslateContext) -> String {
|
||||
ctx.child_table.clone().unwrap_or_else(|| "the referencing table".to_string())
|
||||
ctx.child_table
|
||||
.clone()
|
||||
.unwrap_or_else(|| "the referencing table".to_string())
|
||||
}
|
||||
|
||||
/// Extract `T.col` from a message like
|
||||
@@ -847,11 +831,7 @@ fn parse_after_word<'a>(message: &'a str, keyword: &str) -> Option<&'a str> {
|
||||
let rest = message[pos..].trim_start();
|
||||
let token_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
|
||||
let token = rest[..token_end].trim_matches(|c: char| c == '`' || c == '\'');
|
||||
if token.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(token)
|
||||
}
|
||||
if token.is_empty() { None } else { Some(token) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -876,15 +856,24 @@ mod tests {
|
||||
};
|
||||
let d = TranslateContext::default;
|
||||
assert_eq!(
|
||||
error_hint_class(&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"), &d()),
|
||||
error_hint_class(
|
||||
&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"),
|
||||
&d()
|
||||
),
|
||||
Some("not_found")
|
||||
);
|
||||
assert_eq!(
|
||||
error_hint_class(&sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"), &d()),
|
||||
error_hint_class(
|
||||
&sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"),
|
||||
&d()
|
||||
),
|
||||
Some("not_found")
|
||||
);
|
||||
assert_eq!(
|
||||
error_hint_class(&sqlite(SqliteErrorKind::AlreadyExists, "already exists"), &d()),
|
||||
error_hint_class(
|
||||
&sqlite(SqliteErrorKind::AlreadyExists, "already exists"),
|
||||
&d()
|
||||
),
|
||||
Some("already_exists")
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -933,13 +922,19 @@ mod tests {
|
||||
parent_table: Some("Parent".to_string()),
|
||||
..TranslateContext::default()
|
||||
};
|
||||
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.child_side"));
|
||||
assert_eq!(
|
||||
error_hint_class(&fk(), &ctx),
|
||||
Some("foreign_key.child_side")
|
||||
);
|
||||
// child_table populated → parent-side.
|
||||
let ctx = TranslateContext {
|
||||
child_table: Some("Child".to_string()),
|
||||
..TranslateContext::default()
|
||||
};
|
||||
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.parent_side"));
|
||||
assert_eq!(
|
||||
error_hint_class(&fk(), &ctx),
|
||||
Some("foreign_key.parent_side")
|
||||
);
|
||||
// No enrichment: operation is the tiebreaker.
|
||||
assert_eq!(
|
||||
error_hint_class(&fk(), &ctx_with(Operation::Delete)),
|
||||
@@ -1049,14 +1044,22 @@ mod tests {
|
||||
ctx.parent_column = Some("country, code".to_string());
|
||||
ctx.value = Some("7, 8".to_string());
|
||||
let f = translate(&err, &ctx);
|
||||
assert!(f.headline.contains("no parent row"), "child-side: {}", f.headline);
|
||||
assert!(
|
||||
f.headline.contains("no parent row"),
|
||||
"child-side: {}",
|
||||
f.headline
|
||||
);
|
||||
assert!(f.headline.contains("Region"));
|
||||
assert!(
|
||||
f.headline.contains("country, code"),
|
||||
"both parent columns must appear: {}",
|
||||
f.headline
|
||||
);
|
||||
assert!(f.headline.contains("`7, 8`"), "joined value: {}", f.headline);
|
||||
assert!(
|
||||
f.headline.contains("`7, 8`"),
|
||||
"joined value: {}",
|
||||
f.headline
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+133
-137
@@ -25,9 +25,9 @@
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
use crate::dsl::parser::{parse_command_with_schema, parse_command_with_schema_in_mode};
|
||||
use crate::mode::Mode;
|
||||
use crate::dsl::walker;
|
||||
use crate::dsl::{ParseError, parse_command};
|
||||
use crate::mode::Mode;
|
||||
use crate::theme::Theme;
|
||||
|
||||
/// A run of text with its byte range in the source and the
|
||||
@@ -85,7 +85,16 @@ pub fn render_input_runs_in_mode(
|
||||
mode: Mode,
|
||||
) -> Vec<StyledRun> {
|
||||
// Identity feedback view — highlight/overlay the whole input.
|
||||
render_input_runs_feedback(input, cursor_byte, theme, cache, mode, input, cursor_byte, 0)
|
||||
render_input_runs_feedback(
|
||||
input,
|
||||
cursor_byte,
|
||||
theme,
|
||||
cache,
|
||||
mode,
|
||||
input,
|
||||
cursor_byte,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`render_input_runs_in_mode`] with a separate **feedback view** for
|
||||
@@ -121,12 +130,14 @@ pub fn render_input_runs_feedback(
|
||||
byte_range: (0, offset),
|
||||
style: ratatui::style::Style::default().fg(theme.fg),
|
||||
}];
|
||||
r.extend(lex_to_runs_in_mode(view, theme, mode).into_iter().map(|run| {
|
||||
StyledRun {
|
||||
r.extend(
|
||||
lex_to_runs_in_mode(view, theme, mode)
|
||||
.into_iter()
|
||||
.map(|run| StyledRun {
|
||||
byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset),
|
||||
..run
|
||||
}
|
||||
}));
|
||||
}),
|
||||
);
|
||||
r
|
||||
};
|
||||
if let InputState::DefiniteErrorAt(pos) =
|
||||
@@ -150,7 +161,11 @@ pub fn render_input_runs_feedback(
|
||||
walker::Severity::Error => theme.tok_error,
|
||||
walker::Severity::Warning => theme.warning,
|
||||
};
|
||||
overlay_span(&mut runs, (diag.span.0 + offset, diag.span.1 + offset), colour);
|
||||
overlay_span(
|
||||
&mut runs,
|
||||
(diag.span.0 + offset, diag.span.1 + offset),
|
||||
colour,
|
||||
);
|
||||
}
|
||||
inject_cursor(&mut runs, input, cursor_byte, theme);
|
||||
runs
|
||||
@@ -234,9 +249,7 @@ pub fn classify_input_with_schema_in_mode(
|
||||
))
|
||||
}
|
||||
|
||||
fn classify_parse_result(
|
||||
result: Result<crate::dsl::Command, ParseError>,
|
||||
) -> InputState {
|
||||
fn classify_parse_result(result: Result<crate::dsl::Command, ParseError>) -> InputState {
|
||||
match result {
|
||||
Ok(_) => InputState::Valid,
|
||||
Err(ParseError::Empty) => InputState::Empty,
|
||||
@@ -372,8 +385,7 @@ pub fn advanced_alternative_note(
|
||||
// carries a blocking ERROR diagnostic such as a value-count
|
||||
// mismatch. Incomplete input (still being typed) and empty input are
|
||||
// excluded so the pointer doesn't flicker mid-keystroke.
|
||||
let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple)
|
||||
{
|
||||
let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple) {
|
||||
InputState::DefiniteErrorAt(_) => true,
|
||||
InputState::Valid => {
|
||||
crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Simple)
|
||||
@@ -386,8 +398,7 @@ pub fn advanced_alternative_note(
|
||||
}
|
||||
// The validity-verdict-driven gate (ADR-0033 Amendment 5): the
|
||||
// line must be fully valid (verdict `None`) in advanced mode.
|
||||
if crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Advanced).is_some()
|
||||
{
|
||||
if crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Advanced).is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(crate::t!("advanced_mode.also_valid_sql"))
|
||||
@@ -714,8 +725,7 @@ fn ambient_hint_core_in_mode(
|
||||
// narrows column candidates to the active table and runs the
|
||||
// §10.6 look-ahead, so it is the authoritative "what can go
|
||||
// here" set.
|
||||
let completion =
|
||||
crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode);
|
||||
let completion = crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode);
|
||||
|
||||
// Schema-aware diagnostics (ADR-0027 §2). `input_diagnostics`
|
||||
// is non-empty only for a command that *structurally parses*
|
||||
@@ -834,7 +844,9 @@ fn ambient_hint_core_in_mode(
|
||||
// keyword set.
|
||||
return Some(AmbientHint::Prose(crate::friendly::translate(key, &[])));
|
||||
}
|
||||
Some(crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default)
|
||||
Some(
|
||||
crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default,
|
||||
)
|
||||
| None => {}
|
||||
}
|
||||
|
||||
@@ -855,7 +867,8 @@ fn ambient_hint_core_in_mode(
|
||||
// Invalid identifier: cursor sits in a known-set slot but
|
||||
// the typed prefix matches nothing in the schema. (Stage
|
||||
// 8e / the user's #5.)
|
||||
if let Some(inv) = crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode)
|
||||
if let Some(inv) =
|
||||
crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode)
|
||||
{
|
||||
let kind = match inv.source {
|
||||
crate::dsl::grammar::IdentSource::Tables => "table",
|
||||
@@ -1036,11 +1049,7 @@ pub fn lex_to_runs(input: &str, theme: &Theme) -> Vec<StyledRun> {
|
||||
/// with `Mode::Advanced` so SQL keywords past the entry word
|
||||
/// match and get highlighted (ADR-0030 §8).
|
||||
#[must_use]
|
||||
pub fn lex_to_runs_in_mode(
|
||||
input: &str,
|
||||
theme: &Theme,
|
||||
mode: Mode,
|
||||
) -> Vec<StyledRun> {
|
||||
pub fn lex_to_runs_in_mode(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
|
||||
base_runs(input, theme, mode)
|
||||
}
|
||||
|
||||
@@ -1076,12 +1085,7 @@ fn base_runs(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
|
||||
runs
|
||||
}
|
||||
|
||||
fn inject_cursor(
|
||||
runs: &mut Vec<StyledRun>,
|
||||
input: &str,
|
||||
cursor_byte: usize,
|
||||
theme: &Theme,
|
||||
) {
|
||||
fn inject_cursor(runs: &mut Vec<StyledRun>, input: &str, cursor_byte: usize, theme: &Theme) {
|
||||
let cursor_byte = cursor_byte.min(input.len());
|
||||
|
||||
// End-of-input cursor: append the empty-range sentinel.
|
||||
@@ -1164,9 +1168,10 @@ mod tests {
|
||||
let mut cache = SchemaCache::default();
|
||||
cache.tables.push("Customers".into());
|
||||
cache.columns.push("name".into());
|
||||
cache
|
||||
.table_columns
|
||||
.insert("Customers".into(), vec![TableColumn::new("name", Type::Text)]);
|
||||
cache.table_columns.insert(
|
||||
"Customers".into(),
|
||||
vec![TableColumn::new("name", Type::Text)],
|
||||
);
|
||||
let input = ": select name from Customers";
|
||||
let view = "select name from Customers";
|
||||
let offset = 2; // ": "
|
||||
@@ -1362,9 +1367,10 @@ mod tests {
|
||||
let mut cache = crate::completion::SchemaCache::default();
|
||||
cache.tables.push("users".to_string());
|
||||
cache.columns.push("email".to_string());
|
||||
cache
|
||||
.table_columns
|
||||
.insert("users".to_string(), vec![TableColumn::new("email", Type::Text)]);
|
||||
cache.table_columns.insert(
|
||||
"users".to_string(),
|
||||
vec![TableColumn::new("email", Type::Text)],
|
||||
);
|
||||
cache
|
||||
}
|
||||
|
||||
@@ -1392,7 +1398,10 @@ mod tests {
|
||||
}
|
||||
// Tab candidates remain available (completion is independent).
|
||||
let comp = crate::completion::candidates_at_cursor_in_mode(
|
||||
input, input.len(), &cache, Mode::Simple,
|
||||
input,
|
||||
input.len(),
|
||||
&cache,
|
||||
Mode::Simple,
|
||||
)
|
||||
.expect("completion remains available");
|
||||
let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
|
||||
@@ -1424,7 +1433,10 @@ mod tests {
|
||||
&hint,
|
||||
Some(AmbientHint::Prose(p)) if p.contains("row count")
|
||||
);
|
||||
assert!(!is_count_prose, "count hint must not show for {input:?}; got {hint:?}");
|
||||
assert!(
|
||||
!is_count_prose,
|
||||
"count hint must not show for {input:?}; got {hint:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1502,14 +1514,12 @@ mod tests {
|
||||
use crate::dsl::types::Type;
|
||||
let mut cache = SchemaCache::default();
|
||||
cache.tables.push("Customers".to_string());
|
||||
let tc = vec![
|
||||
TableColumn {
|
||||
let tc = vec![TableColumn {
|
||||
name: "Age".to_string(),
|
||||
user_type: Type::Int,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
},
|
||||
];
|
||||
}];
|
||||
for c in &tc {
|
||||
cache.columns.push(c.name.clone());
|
||||
}
|
||||
@@ -1551,9 +1561,7 @@ mod tests {
|
||||
p.contains("No such") && p.contains("Agx"),
|
||||
"a genuine column typo before FROM must warn at typing time; got: {p:?}",
|
||||
),
|
||||
other => panic!(
|
||||
"`select Agx` must surface a typing-time typo hint; got: {other:?}",
|
||||
),
|
||||
other => panic!("`select Agx` must surface a typing-time typo hint; got: {other:?}",),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1652,8 +1660,7 @@ mod tests {
|
||||
// ADR-0022 Amendment 1: advanced-mode ambient assistance
|
||||
// surfaces SQL completion candidates (here the FROM-slot
|
||||
// table) instead of the simple-mode "this is SQL" gate.
|
||||
let cache =
|
||||
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
||||
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
||||
let input = "select * from ";
|
||||
match ambient_hint_in_mode(
|
||||
input,
|
||||
@@ -1678,10 +1685,7 @@ mod tests {
|
||||
// `INSERT … (` column list. (The simple-mode DSL value-slot
|
||||
// prose is a separate surface; this pins the §8 advanced claim.)
|
||||
use crate::dsl::types::Type;
|
||||
let cache = schema_with_columns(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Name", Type::Text)],
|
||||
);
|
||||
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
|
||||
|
||||
let set_slot = "update Customers set ";
|
||||
match ambient_hint_in_mode(set_slot, set_slot.len(), None, &cache, Mode::Advanced) {
|
||||
@@ -1706,16 +1710,10 @@ mod tests {
|
||||
fn simple_mode_ambient_does_not_surface_sql_candidates() {
|
||||
// The simple-mode entry point keeps gating SQL — advanced
|
||||
// assistance is opt-in via mode, never leaked into simple.
|
||||
let cache =
|
||||
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
||||
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
||||
let input = "select * from ";
|
||||
let hint = ambient_hint_in_mode(
|
||||
input,
|
||||
input.len(),
|
||||
None,
|
||||
&cache,
|
||||
crate::mode::Mode::Simple,
|
||||
);
|
||||
let hint =
|
||||
ambient_hint_in_mode(input, input.len(), None, &cache, crate::mode::Mode::Simple);
|
||||
let offers_table = matches!(
|
||||
&hint,
|
||||
Some(AmbientHint::Candidates { items, .. })
|
||||
@@ -1733,8 +1731,7 @@ mod tests {
|
||||
fn f1_mid_typed_table_prefix_shows_completion_not_error() {
|
||||
// "select * from c" — `c` prefix-matches `Customers`. The
|
||||
// hint must offer the completion, not "no such table c".
|
||||
let cache =
|
||||
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
||||
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
||||
match ambient_hint_in_mode(
|
||||
"select * from c",
|
||||
"select * from c".len(),
|
||||
@@ -1753,8 +1750,7 @@ mod tests {
|
||||
#[test]
|
||||
fn f1_genuinely_unknown_table_still_shows_error() {
|
||||
// "zzz" matches no table prefix — the error must still show.
|
||||
let cache =
|
||||
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
||||
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
||||
match ambient_hint_in_mode(
|
||||
"select * from zzz",
|
||||
"select * from zzz".len(),
|
||||
@@ -1773,8 +1769,7 @@ mod tests {
|
||||
fn f1_simple_mode_dsl_mid_typed_table_completes() {
|
||||
// The same shadowing affects DSL commands in simple mode:
|
||||
// "show data c" must offer Customers, not "no such table c".
|
||||
let cache =
|
||||
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
||||
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
||||
match ambient_hint_in_mode(
|
||||
"show data c",
|
||||
"show data c".len(),
|
||||
@@ -1804,7 +1799,12 @@ mod tests {
|
||||
cache.columns.push("order_col".to_string());
|
||||
cache.table_columns.insert(
|
||||
"Orders".to_string(),
|
||||
vec![TableColumn { name: "order_col".to_string(), user_type: Type::Int, not_null: false, has_default: false }],
|
||||
vec![TableColumn {
|
||||
name: "order_col".to_string(),
|
||||
user_type: Type::Int,
|
||||
not_null: false,
|
||||
has_default: false,
|
||||
}],
|
||||
);
|
||||
|
||||
let comp = crate::completion::candidates_at_cursor_in_mode(
|
||||
@@ -1846,9 +1846,7 @@ mod tests {
|
||||
for c in &columns {
|
||||
cache.columns.push(c.name.clone());
|
||||
}
|
||||
cache
|
||||
.table_columns
|
||||
.insert(table.to_string(), columns);
|
||||
cache.table_columns.insert(table.to_string(), columns);
|
||||
cache
|
||||
}
|
||||
|
||||
@@ -1860,7 +1858,11 @@ mod tests {
|
||||
("Customers", &[("id", Type::Serial), ("name", Type::Text)]),
|
||||
(
|
||||
"Products",
|
||||
&[("id", Type::Serial), ("name", Type::Text), ("price", Type::Decimal)],
|
||||
&[
|
||||
("id", Type::Serial),
|
||||
("name", Type::Text),
|
||||
("price", Type::Decimal),
|
||||
],
|
||||
),
|
||||
(
|
||||
"OrderLines",
|
||||
@@ -1873,13 +1875,19 @@ mod tests {
|
||||
),
|
||||
(
|
||||
"Orders",
|
||||
&[("id", Type::Serial), ("customer_id", Type::Int), ("date", Type::Date)],
|
||||
&[
|
||||
("id", Type::Serial),
|
||||
("customer_id", Type::Int),
|
||||
("date", Type::Date),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (t, cols) in tables {
|
||||
cache.tables.push((*t).to_string());
|
||||
let tc: Vec<TableColumn> =
|
||||
cols.iter().map(|(n, ty)| TableColumn::new(*n, *ty)).collect();
|
||||
let tc: Vec<TableColumn> = cols
|
||||
.iter()
|
||||
.map(|(n, ty)| TableColumn::new(*n, *ty))
|
||||
.collect();
|
||||
for c in &tc {
|
||||
cache.columns.push(c.name.clone());
|
||||
}
|
||||
@@ -1914,17 +1922,11 @@ mod tests {
|
||||
#[test]
|
||||
fn ambient_hint_at_insert_first_value_shows_int_prose() {
|
||||
use crate::dsl::types::Type;
|
||||
let cache = schema_with_columns(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Name", Type::Text)],
|
||||
);
|
||||
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
|
||||
let input = "insert into Customers values (";
|
||||
match ambient_hint(input, input.len(), None, &cache) {
|
||||
Some(AmbientHint::Prose(p)) => {
|
||||
assert!(
|
||||
p.contains("integer"),
|
||||
"expected int-slot prose, got: {p:?}",
|
||||
);
|
||||
assert!(p.contains("integer"), "expected int-slot prose, got: {p:?}",);
|
||||
}
|
||||
other => panic!("expected Prose, got {other:?}"),
|
||||
}
|
||||
@@ -1942,7 +1944,11 @@ mod tests {
|
||||
use crate::dsl::types::Type;
|
||||
let cache = schema_with_columns(
|
||||
"Customers",
|
||||
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
|
||||
&[
|
||||
("id", Type::Serial),
|
||||
("Name", Type::Text),
|
||||
("Email", Type::Text),
|
||||
],
|
||||
);
|
||||
let input = "insert into Customers values (1, 'Alice', 'a@b.c')";
|
||||
match ambient_hint(input, input.len(), None, &cache) {
|
||||
@@ -1966,10 +1972,7 @@ mod tests {
|
||||
// A valid simple-mode DSL command gets no advanced pointer —
|
||||
// it isn't an error, and there is nothing to switch modes for.
|
||||
use crate::dsl::types::Type;
|
||||
let cache = schema_with_columns(
|
||||
"Customers",
|
||||
&[("id", Type::Serial), ("Name", Type::Text)],
|
||||
);
|
||||
let cache = schema_with_columns("Customers", &[("id", Type::Serial), ("Name", Type::Text)]);
|
||||
let input = "insert into Customers values ('Alice')";
|
||||
if let Some(AmbientHint::Prose(p)) = ambient_hint(input, input.len(), None, &cache) {
|
||||
assert!(
|
||||
@@ -2010,10 +2013,7 @@ mod tests {
|
||||
#[test]
|
||||
fn ambient_hint_at_insert_second_value_shows_text_prose() {
|
||||
use crate::dsl::types::Type;
|
||||
let cache = schema_with_columns(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Name", Type::Text)],
|
||||
);
|
||||
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
|
||||
let input = "insert into Customers values (1, ";
|
||||
match ambient_hint(input, input.len(), None, &cache) {
|
||||
Some(AmbientHint::Prose(p)) => {
|
||||
@@ -2029,10 +2029,8 @@ mod tests {
|
||||
#[test]
|
||||
fn ambient_hint_at_update_set_shows_per_column_prose() {
|
||||
use crate::dsl::types::Type;
|
||||
let cache = schema_with_columns(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Birthday", Type::Date)],
|
||||
);
|
||||
let cache =
|
||||
schema_with_columns("Customers", &[("id", Type::Int), ("Birthday", Type::Date)]);
|
||||
let input = "update Customers set Birthday=";
|
||||
match ambient_hint(input, input.len(), None, &cache) {
|
||||
Some(AmbientHint::Prose(p)) => {
|
||||
@@ -2057,9 +2055,7 @@ mod tests {
|
||||
// hasn't committed), the Optional propagates Incomplete
|
||||
// and the user sees no error overlay until they submit.
|
||||
assert_eq!(
|
||||
classify_input(
|
||||
"insert into Orders (id, CustId, Total) values (42, 89, 17.59"
|
||||
),
|
||||
classify_input("insert into Orders (id, CustId, Total) values (42, 89, 17.59"),
|
||||
InputState::IncompleteAtEof,
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -2071,18 +2067,12 @@ mod tests {
|
||||
#[test]
|
||||
fn ambient_hint_at_insert_first_value_mentions_column_name() {
|
||||
use crate::dsl::types::Type;
|
||||
let cache = schema_with_columns(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Name", Type::Text)],
|
||||
);
|
||||
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
|
||||
let input = "insert into Customers values (";
|
||||
match ambient_hint(input, input.len(), None, &cache) {
|
||||
Some(AmbientHint::Prose(p)) => {
|
||||
assert!(p.contains("id"), "expected column name `id`, got {p:?}");
|
||||
assert!(
|
||||
p.contains("integer"),
|
||||
"expected int prose, got {p:?}",
|
||||
);
|
||||
assert!(p.contains("integer"), "expected int prose, got {p:?}",);
|
||||
}
|
||||
other => panic!("expected Prose, got {other:?}"),
|
||||
}
|
||||
@@ -2091,10 +2081,7 @@ mod tests {
|
||||
#[test]
|
||||
fn ambient_hint_at_update_set_mentions_column_name() {
|
||||
use crate::dsl::types::Type;
|
||||
let cache = schema_with_columns(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Email", Type::Text)],
|
||||
);
|
||||
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
|
||||
let input = "update Customers set Email=";
|
||||
match ambient_hint(input, input.len(), None, &cache) {
|
||||
Some(AmbientHint::Prose(p)) => {
|
||||
@@ -2131,10 +2118,7 @@ mod tests {
|
||||
#[test]
|
||||
fn ambient_hint_at_second_insert_value_mentions_second_column() {
|
||||
use crate::dsl::types::Type;
|
||||
let cache = schema_with_columns(
|
||||
"Customers",
|
||||
&[("id", Type::Int), ("Name", Type::Text)],
|
||||
);
|
||||
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
|
||||
let input = "insert into Customers values (1, ";
|
||||
match ambient_hint(input, input.len(), None, &cache) {
|
||||
Some(AmbientHint::Prose(p)) => {
|
||||
@@ -2165,14 +2149,20 @@ mod tests {
|
||||
use crate::dsl::types::Type;
|
||||
let cases: &[(&[(&str, Type)], &str)] = &[
|
||||
// string first value (the report's case): first col text.
|
||||
(&[("Name", Type::Text), ("Age", Type::Int)],
|
||||
"insert into Customers values ('Oli'"),
|
||||
(
|
||||
&[("Name", Type::Text), ("Age", Type::Int)],
|
||||
"insert into Customers values ('Oli'",
|
||||
),
|
||||
// integer first value: first col int.
|
||||
(&[("Age", Type::Int), ("Name", Type::Text)],
|
||||
"insert into Customers values (42"),
|
||||
(
|
||||
&[("Age", Type::Int), ("Name", Type::Text)],
|
||||
"insert into Customers values (42",
|
||||
),
|
||||
// real first value: first col real.
|
||||
(&[("Score", Type::Real), ("Name", Type::Text)],
|
||||
"insert into Customers values (3.5"),
|
||||
(
|
||||
&[("Score", Type::Real), ("Name", Type::Text)],
|
||||
"insert into Customers values (3.5",
|
||||
),
|
||||
];
|
||||
for (cols, input) in cases {
|
||||
let cache = schema_with_columns("Customers", cols);
|
||||
@@ -2232,10 +2222,7 @@ mod tests {
|
||||
// is nothing left to fill. Guards against over-correcting the
|
||||
// fix into never suggesting the close paren.
|
||||
use crate::dsl::types::Type;
|
||||
let cache = schema_with_columns(
|
||||
"Customers",
|
||||
&[("Name", Type::Text), ("Age", Type::Int)],
|
||||
);
|
||||
let cache = schema_with_columns("Customers", &[("Name", Type::Text), ("Age", Type::Int)]);
|
||||
let input = "insert into Customers values ('Oli', 52";
|
||||
match ambient_hint(input, input.len(), None, &cache) {
|
||||
Some(AmbientHint::Prose(p)) => {
|
||||
@@ -2384,10 +2371,7 @@ mod tests {
|
||||
match ambient_hint("show data Missing", 17, None, &cache) {
|
||||
Some(AmbientHint::Prose(p)) => {
|
||||
assert!(p.contains("Missing"), "got {p:?}");
|
||||
assert!(
|
||||
p.to_lowercase().contains("no such table"),
|
||||
"got {p:?}",
|
||||
);
|
||||
assert!(p.to_lowercase().contains("no such table"), "got {p:?}",);
|
||||
}
|
||||
other => panic!("expected Prose, got {other:?}"),
|
||||
}
|
||||
@@ -2440,8 +2424,7 @@ mod tests {
|
||||
use crate::dsl::types::Type;
|
||||
// Two type-mismatch WARNINGs; the hint names the column
|
||||
// whose offending literal the cursor sits in.
|
||||
let cache =
|
||||
schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]);
|
||||
let cache = schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]);
|
||||
let input = "delete from Events where a = 'x' or b = 'y'";
|
||||
let on_x = input.find("'x'").expect("'x' literal") + 1;
|
||||
let on_y = input.find("'y'").expect("'y' literal") + 1;
|
||||
@@ -2460,8 +2443,16 @@ mod tests {
|
||||
inserted_range: (5, 5),
|
||||
original_text: String::new(),
|
||||
candidates: vec![
|
||||
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
|
||||
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
|
||||
Candidate {
|
||||
text: "data".to_string(),
|
||||
kind: CandidateKind::Keyword,
|
||||
mode: crate::completion::ModeClass::Both,
|
||||
},
|
||||
Candidate {
|
||||
text: "table".to_string(),
|
||||
kind: CandidateKind::Keyword,
|
||||
mode: crate::completion::ModeClass::Both,
|
||||
},
|
||||
],
|
||||
selection_idx: 1,
|
||||
};
|
||||
@@ -2494,8 +2485,16 @@ mod tests {
|
||||
// produce — proves the memo's list is being used,
|
||||
// not a recomputed one.
|
||||
candidates: vec![
|
||||
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
|
||||
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
|
||||
Candidate {
|
||||
text: "data".to_string(),
|
||||
kind: CandidateKind::Keyword,
|
||||
mode: crate::completion::ModeClass::Both,
|
||||
},
|
||||
Candidate {
|
||||
text: "table".to_string(),
|
||||
kind: CandidateKind::Keyword,
|
||||
mode: crate::completion::ModeClass::Both,
|
||||
},
|
||||
],
|
||||
selection_idx: 1,
|
||||
};
|
||||
@@ -2564,10 +2563,7 @@ mod tests {
|
||||
fn classify_trailing_whitespace_does_not_create_definite_error() {
|
||||
// Trailing whitespace alone shouldn't promote an
|
||||
// incomplete-at-EOF state into a definite error.
|
||||
assert_eq!(
|
||||
classify_input("create "),
|
||||
InputState::IncompleteAtEof,
|
||||
);
|
||||
assert_eq!(classify_input("create "), InputState::IncompleteAtEof,);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+3
-4
@@ -60,8 +60,8 @@ pub fn init(path: Option<&Path>) -> Result<PathBuf> {
|
||||
.with_context(|| format!("create log directory {}", parent.display()))?;
|
||||
}
|
||||
let file = open_log_file(&chosen)?;
|
||||
let filter = EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG")
|
||||
.unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
let filter =
|
||||
EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG").unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
let layer = fmt::layer()
|
||||
.with_writer(file)
|
||||
.with_ansi(false)
|
||||
@@ -95,8 +95,7 @@ fn home_dir() -> Option<PathBuf> {
|
||||
if let Some(p) = std::env::var_os("HOME") {
|
||||
return Some(PathBuf::from(p));
|
||||
}
|
||||
if let (Some(drive), Some(path)) =
|
||||
(std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
|
||||
if let (Some(drive), Some(path)) = (std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
|
||||
{
|
||||
let mut combined = PathBuf::from(drive);
|
||||
combined.push(path);
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
use rdbms_playground::cli::{help_text, Args};
|
||||
use rdbms_playground::cli::{Args, help_text, version_text};
|
||||
use rdbms_playground::{logging, runtime};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -22,6 +22,11 @@ fn main() -> ExitCode {
|
||||
}
|
||||
};
|
||||
|
||||
if args.version {
|
||||
println!("{}", version_text());
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
if args.help {
|
||||
print!("{}", help_text());
|
||||
return ExitCode::SUCCESS;
|
||||
|
||||
+69
-42
@@ -172,7 +172,10 @@ fn constraint_lines(desc: &TableDescription) -> Vec<String> {
|
||||
/// A `detail` matching no marker renders neutral — the engine's
|
||||
/// plan vocabulary may grow (ADR-0028 §4).
|
||||
const PLAN_TAXONOMY: &[(&str, OutputStyleClass)] = &[
|
||||
("USING AUTOMATIC COVERING INDEX", OutputStyleClass::AutomaticIndex),
|
||||
(
|
||||
"USING AUTOMATIC COVERING INDEX",
|
||||
OutputStyleClass::AutomaticIndex,
|
||||
),
|
||||
("USING AUTOMATIC INDEX", OutputStyleClass::AutomaticIndex),
|
||||
("USING COVERING INDEX", OutputStyleClass::Efficient),
|
||||
("USING INTEGER PRIMARY KEY", OutputStyleClass::Efficient),
|
||||
@@ -225,8 +228,7 @@ fn render_plan_subtree(
|
||||
emitted: &mut HashSet<i64>,
|
||||
mode: Mode,
|
||||
) {
|
||||
let children: Vec<&ExplainRow> =
|
||||
rows.iter().filter(|r| r.parent == parent).collect();
|
||||
let children: Vec<&ExplainRow> = rows.iter().filter(|r| r.parent == parent).collect();
|
||||
let last_idx = children.len().saturating_sub(1);
|
||||
for (idx, row) in children.iter().enumerate() {
|
||||
if !emitted.insert(row.id) {
|
||||
@@ -235,8 +237,7 @@ fn render_plan_subtree(
|
||||
let is_last = idx == last_idx;
|
||||
let connector = if is_last { "└─ " } else { "├─ " };
|
||||
out.push(plan_node_line(prefix, connector, &row.detail, mode));
|
||||
let child_prefix =
|
||||
format!("{prefix}{}", if is_last { " " } else { "│ " });
|
||||
let child_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " });
|
||||
render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode);
|
||||
}
|
||||
}
|
||||
@@ -343,13 +344,8 @@ pub fn render_diagnostic_table(
|
||||
const fn alignment_for(ty: Option<Type>) -> Alignment {
|
||||
match ty {
|
||||
Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right,
|
||||
Some(Type::Text)
|
||||
| Some(Type::Bool)
|
||||
| Some(Type::Date)
|
||||
| Some(Type::DateTime)
|
||||
| Some(Type::Blob)
|
||||
| Some(Type::ShortId)
|
||||
| None => Alignment::Left,
|
||||
Some(Type::Text) | Some(Type::Bool) | Some(Type::Date) | Some(Type::DateTime)
|
||||
| Some(Type::Blob) | Some(Type::ShortId) | None => Alignment::Left,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,11 +402,7 @@ fn cell_width(s: &str) -> usize {
|
||||
/// Render a single bordered table given header cells, body
|
||||
/// rows, and per-column alignment. Outer frame +
|
||||
/// header-underline only.
|
||||
fn render_table(
|
||||
headers: &[String],
|
||||
body: &[Vec<String>],
|
||||
alignments: &[Alignment],
|
||||
) -> Vec<String> {
|
||||
fn render_table(headers: &[String], body: &[Vec<String>], alignments: &[Alignment]) -> Vec<String> {
|
||||
debug_assert_eq!(headers.len(), alignments.len());
|
||||
|
||||
// Compute column widths: max(header, all body cells).
|
||||
@@ -792,13 +784,12 @@ fn gutter_seg(i: usize, child_rows: &[usize], parent_rows: &[usize], w: usize) -
|
||||
}
|
||||
|
||||
// The vertical bus spans the full range of endpoint rows.
|
||||
let bounds = child_rows
|
||||
.iter()
|
||||
.chain(parent_rows)
|
||||
.copied()
|
||||
.fold(None, |acc: Option<(usize, usize)>, r| {
|
||||
let bounds = child_rows.iter().chain(parent_rows).copied().fold(
|
||||
None,
|
||||
|acc: Option<(usize, usize)>, r| {
|
||||
Some(acc.map_or((r, r), |(lo, hi)| (lo.min(r), hi.max(r))))
|
||||
});
|
||||
},
|
||||
);
|
||||
if let Some((top, bot)) = bounds
|
||||
&& i >= top
|
||||
&& i <= bot
|
||||
@@ -1138,7 +1129,10 @@ mod tests {
|
||||
assert!(out.contains("customer_id ●"), "FK marker:\n{out}");
|
||||
assert!(out.contains("id (PK) ●"), "parent endpoint marker:\n{out}");
|
||||
assert!(out.contains('▶'), "arrowhead:\n{out}");
|
||||
assert!(out.contains('n') && out.contains('1'), "cardinality:\n{out}");
|
||||
assert!(
|
||||
out.contains('n') && out.contains('1'),
|
||||
"cardinality:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("on delete cascade · on update no action"),
|
||||
"actions:\n{out}"
|
||||
@@ -1237,7 +1231,10 @@ mod tests {
|
||||
let (r_out, r_in) = blank_rels();
|
||||
let region = TableDescription {
|
||||
name: "Region".to_string(),
|
||||
columns: vec![col("country", Type::Int, true, false), col("code", Type::Int, true, false)],
|
||||
columns: vec![
|
||||
col("country", Type::Int, true, false),
|
||||
col("code", Type::Int, true, false),
|
||||
],
|
||||
outbound_relationships: r_out,
|
||||
inbound_relationships: r_in,
|
||||
indexes: Vec::new(),
|
||||
@@ -1277,7 +1274,10 @@ mod tests {
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("region_code ●"), "child endpoint 2:\n{text}");
|
||||
assert!(text.contains("(PK) ●"), "parent endpoint is PK + marked:\n{text}");
|
||||
assert!(
|
||||
text.contains("(PK) ●"),
|
||||
"parent endpoint is PK + marked:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("(country, region_code) ▶ Region.(country, code)"),
|
||||
"pairing line:\n{text}",
|
||||
@@ -1412,11 +1412,7 @@ mod tests {
|
||||
let data = DataResult {
|
||||
table_name: "Customers".to_string(),
|
||||
columns: vec!["id".to_string(), "Name".to_string(), "Email".to_string()],
|
||||
column_types: vec![
|
||||
Some(Type::Serial),
|
||||
Some(Type::Text),
|
||||
Some(Type::Text),
|
||||
],
|
||||
column_types: vec![Some(Type::Serial), Some(Type::Text), Some(Type::Text)],
|
||||
rows: vec![
|
||||
vec![
|
||||
Some("1".to_string()),
|
||||
@@ -1634,7 +1630,10 @@ mod tests {
|
||||
assert!(out.contains("Indexes:"), "got:\n{out}");
|
||||
assert!(out.contains("idx_email (Email)"), "got:\n{out}");
|
||||
// A plain index carries no uniqueness marker.
|
||||
assert!(!out.contains("[unique]"), "plain index unmarked; got:\n{out}");
|
||||
assert!(
|
||||
!out.contains("[unique]"),
|
||||
"plain index unmarked; got:\n{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1677,7 +1676,10 @@ mod tests {
|
||||
indexes: Vec::new(),
|
||||
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
|
||||
check_constraints: vec![
|
||||
crate::persistence::TableCheck { name: None, expr: "a < b".to_string() },
|
||||
crate::persistence::TableCheck {
|
||||
name: None,
|
||||
expr: "a < b".to_string(),
|
||||
},
|
||||
crate::persistence::TableCheck {
|
||||
name: Some("a_lt_b".to_string()),
|
||||
expr: "a <> b".to_string(),
|
||||
@@ -1691,7 +1693,10 @@ mod tests {
|
||||
// (ADR-0035 Amendment 1) so the user can `drop constraint <name>`.
|
||||
assert!(out.contains("unique_a_b: unique (a, b)"), "got:\n{out}");
|
||||
assert!(out.contains("check (a < b)"), "unnamed check; got:\n{out}");
|
||||
assert!(out.contains("check a_lt_b (a <> b)"), "named check shows its name; got:\n{out}");
|
||||
assert!(
|
||||
out.contains("check a_lt_b (a <> b)"),
|
||||
"named check shows its name; got:\n{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1732,17 +1737,37 @@ mod tests {
|
||||
let plan = QueryPlan {
|
||||
display_sql: "SELECT 1".to_string(),
|
||||
rows: vec![
|
||||
ExplainRow { id: 1, parent: 0, detail: "root".to_string() },
|
||||
ExplainRow { id: 2, parent: 1, detail: "child-a".to_string() },
|
||||
ExplainRow { id: 3, parent: 1, detail: "child-b".to_string() },
|
||||
ExplainRow {
|
||||
id: 1,
|
||||
parent: 0,
|
||||
detail: "root".to_string(),
|
||||
},
|
||||
ExplainRow {
|
||||
id: 2,
|
||||
parent: 1,
|
||||
detail: "child-a".to_string(),
|
||||
},
|
||||
ExplainRow {
|
||||
id: 3,
|
||||
parent: 1,
|
||||
detail: "child-b".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let lines = render_explain_plan(&plan, Mode::Simple);
|
||||
// display SQL + 3 plan nodes.
|
||||
assert_eq!(lines.len(), 4);
|
||||
assert!(lines[1].text.contains("root"));
|
||||
assert!(lines[2].text.contains("├─ child-a"), "got {:?}", lines[2].text);
|
||||
assert!(lines[3].text.contains("└─ child-b"), "got {:?}", lines[3].text);
|
||||
assert!(
|
||||
lines[2].text.contains("├─ child-a"),
|
||||
"got {:?}",
|
||||
lines[2].text
|
||||
);
|
||||
assert!(
|
||||
lines[3].text.contains("└─ child-b"),
|
||||
"got {:?}",
|
||||
lines[3].text
|
||||
);
|
||||
// The single root uses `└─`; its children are indented
|
||||
// by three spaces (no `│` spine, the root being last).
|
||||
assert!(lines[1].text.starts_with("└─ root"));
|
||||
@@ -1775,7 +1800,10 @@ mod tests {
|
||||
fn render_explain_plan_colours_a_full_scan_expensive() {
|
||||
let plan = one_node_plan("SCAN Customers");
|
||||
let lines = render_explain_plan(&plan, Mode::Simple);
|
||||
assert_eq!(span_class_for(&lines[1], "SCAN"), OutputStyleClass::Expensive);
|
||||
assert_eq!(
|
||||
span_class_for(&lines[1], "SCAN"),
|
||||
OutputStyleClass::Expensive
|
||||
);
|
||||
// The table name stays neutral (ADR-0028 §6).
|
||||
assert_eq!(
|
||||
span_class_for(&lines[1], "Customers"),
|
||||
@@ -1801,8 +1829,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn render_explain_plan_flags_an_automatic_index() {
|
||||
let plan =
|
||||
one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
|
||||
let plan = one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
|
||||
let lines = render_explain_plan(&plan, Mode::Simple);
|
||||
assert_eq!(
|
||||
span_class_for(&lines[1], "USING AUTOMATIC COVERING INDEX"),
|
||||
|
||||
+29
-13
@@ -150,7 +150,9 @@ fn encode_cell(ty: Type, value: &CellValue) -> Result<Cell, String> {
|
||||
other => Err(format!("expected date/datetime (text), got {other:?}")),
|
||||
},
|
||||
Type::Blob => match value {
|
||||
CellValue::Blob(bytes) => Ok(Cell::Plain(base64::engine::general_purpose::STANDARD.encode(bytes))),
|
||||
CellValue::Blob(bytes) => Ok(Cell::Plain(
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes),
|
||||
)),
|
||||
other => Err(format!("expected blob, got {other:?}")),
|
||||
},
|
||||
Type::Serial => match value {
|
||||
@@ -169,7 +171,11 @@ fn format_real(f: f64) -> String {
|
||||
if f.is_nan() {
|
||||
"nan".to_string()
|
||||
} else if f.is_infinite() {
|
||||
if f > 0.0 { "inf".to_string() } else { "-inf".to_string() }
|
||||
if f > 0.0 {
|
||||
"inf".to_string()
|
||||
} else {
|
||||
"-inf".to_string()
|
||||
}
|
||||
} else {
|
||||
// Default `{}` formatting on f64 emits a shortest
|
||||
// round-tripping decimal — exactly what the ADR asks
|
||||
@@ -318,8 +324,7 @@ fn parse_field(bytes: &[u8]) -> Result<(RawCell, usize), CsvError> {
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
let content =
|
||||
String::from_utf8(bytes[..i].to_vec()).map_err(|_| CsvError::InvalidUtf8)?;
|
||||
let content = String::from_utf8(bytes[..i].to_vec()).map_err(|_| CsvError::InvalidUtf8)?;
|
||||
Ok((
|
||||
RawCell {
|
||||
content,
|
||||
@@ -435,7 +440,10 @@ mod tests {
|
||||
name: "T".to_string(),
|
||||
columns: vec![col("n", Type::Int), col("r", Type::Real)],
|
||||
rows: vec![
|
||||
vec![CellValue::Integer(42), CellValue::Real(std::f64::consts::PI)],
|
||||
vec![
|
||||
CellValue::Integer(42),
|
||||
CellValue::Real(std::f64::consts::PI),
|
||||
],
|
||||
vec![CellValue::Integer(-7), CellValue::Real(0.0)],
|
||||
],
|
||||
})
|
||||
@@ -452,10 +460,7 @@ mod tests {
|
||||
let body = serialize_table(&TableSnapshot {
|
||||
name: "T".to_string(),
|
||||
columns: vec![col("b", Type::Bool)],
|
||||
rows: vec![
|
||||
vec![CellValue::Integer(1)],
|
||||
vec![CellValue::Integer(0)],
|
||||
],
|
||||
rows: vec![vec![CellValue::Integer(1)], vec![CellValue::Integer(0)]],
|
||||
})
|
||||
.unwrap();
|
||||
let s = String::from_utf8(body).unwrap();
|
||||
@@ -555,13 +560,21 @@ mod tests {
|
||||
let body = serialize_table(&table).unwrap();
|
||||
let parsed = parse_csv(std::str::from_utf8(&body).unwrap()).unwrap();
|
||||
let row = &parsed.rows[0];
|
||||
assert!(matches!(decode_cell(Type::Int, &row[0]).unwrap(), CellValue::Integer(42)));
|
||||
assert!(matches!(
|
||||
decode_cell(Type::Int, &row[0]).unwrap(),
|
||||
CellValue::Integer(42)
|
||||
));
|
||||
match decode_cell(Type::Real, &row[1]).unwrap() {
|
||||
CellValue::Real(f) => assert!((f - std::f64::consts::PI).abs() < 1e-12),
|
||||
other => panic!("got {other:?}"),
|
||||
}
|
||||
assert!(matches!(decode_cell(Type::Bool, &row[2]).unwrap(), CellValue::Integer(1)));
|
||||
assert!(matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi"));
|
||||
assert!(matches!(
|
||||
decode_cell(Type::Bool, &row[2]).unwrap(),
|
||||
CellValue::Integer(1)
|
||||
));
|
||||
assert!(
|
||||
matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -572,7 +585,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn decode_cell_reports_friendly_error_for_bad_int() {
|
||||
let cell = RawCell { content: "abc".to_string(), was_quoted: false };
|
||||
let cell = RawCell {
|
||||
content: "abc".to_string(),
|
||||
was_quoted: false,
|
||||
};
|
||||
let err = decode_cell(Type::Int, &cell).expect_err("must error");
|
||||
assert!(err.contains("integer"));
|
||||
assert!(err.contains("abc"));
|
||||
|
||||
+32
-17
@@ -108,10 +108,7 @@ pub(super) fn read_recent_sources(
|
||||
});
|
||||
}
|
||||
};
|
||||
let mut sources: Vec<String> = body
|
||||
.lines()
|
||||
.filter_map(parse_record_source)
|
||||
.collect();
|
||||
let mut sources: Vec<String> = body.lines().filter_map(parse_record_source).collect();
|
||||
if sources.len() > max_n {
|
||||
let skip = sources.len() - max_n;
|
||||
sources.drain(0..skip);
|
||||
@@ -187,12 +184,26 @@ fn looks_like_iso8601(s: &str) -> bool {
|
||||
return false;
|
||||
}
|
||||
let digit = |i: usize| b[i].is_ascii_digit();
|
||||
digit(0) && digit(1) && digit(2) && digit(3) && b[4] == b'-'
|
||||
&& digit(5) && digit(6) && b[7] == b'-'
|
||||
&& digit(8) && digit(9) && b[10] == b'T'
|
||||
&& digit(11) && digit(12) && b[13] == b':'
|
||||
&& digit(14) && digit(15) && b[16] == b':'
|
||||
&& digit(17) && digit(18) && b[19] == b'Z'
|
||||
digit(0)
|
||||
&& digit(1)
|
||||
&& digit(2)
|
||||
&& digit(3)
|
||||
&& b[4] == b'-'
|
||||
&& digit(5)
|
||||
&& digit(6)
|
||||
&& b[7] == b'-'
|
||||
&& digit(8)
|
||||
&& digit(9)
|
||||
&& b[10] == b'T'
|
||||
&& digit(11)
|
||||
&& digit(12)
|
||||
&& b[13] == b':'
|
||||
&& digit(14)
|
||||
&& digit(15)
|
||||
&& b[16] == b':'
|
||||
&& digit(17)
|
||||
&& digit(18)
|
||||
&& b[19] == b'Z'
|
||||
}
|
||||
|
||||
fn unescape_command(s: &str) -> String {
|
||||
@@ -321,9 +332,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_journal_record_ok_extracts_unescaped_source() {
|
||||
let rec = parse_journal_record(
|
||||
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)",
|
||||
)
|
||||
let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|create table T with pk id(int)")
|
||||
.expect("valid ok journal record");
|
||||
assert!(rec.status_is_ok);
|
||||
assert_eq!(rec.source, "create table T with pk id(int)");
|
||||
@@ -370,8 +379,8 @@ mod tests {
|
||||
fn parse_journal_record_preserves_pipe_in_source() {
|
||||
// `|` is not escaped by the writer (it's a valid SQL char);
|
||||
// `splitn(3, '|')` keeps everything after the second `|`.
|
||||
let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t")
|
||||
.expect("ok record");
|
||||
let rec =
|
||||
parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t").expect("ok record");
|
||||
assert_eq!(rec.source, "select 'a|b' from t");
|
||||
}
|
||||
|
||||
@@ -406,7 +415,10 @@ mod tests {
|
||||
#[test]
|
||||
fn iso8601_known_seconds() {
|
||||
assert_eq!(iso8601_from_unix_secs(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(iso8601_from_unix_secs(1_778_112_000), "2026-05-07T00:00:00Z");
|
||||
assert_eq!(
|
||||
iso8601_from_unix_secs(1_778_112_000),
|
||||
"2026-05-07T00:00:00Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -437,7 +449,10 @@ mod tests {
|
||||
.collect();
|
||||
std::fs::write(&path, body).unwrap();
|
||||
let got = read_recent_sources(&path, 3).unwrap();
|
||||
assert_eq!(got, vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()]);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -82,7 +82,10 @@ impl Default for MigratorRegistry {
|
||||
#[derive(Debug)]
|
||||
pub enum MigrateError {
|
||||
VersionParse(String),
|
||||
NewerThanSupported { file: u32, latest: u32 },
|
||||
NewerThanSupported {
|
||||
file: u32,
|
||||
latest: u32,
|
||||
},
|
||||
NoMigratorForVersion(u32),
|
||||
StepFailed {
|
||||
from: u32,
|
||||
@@ -108,10 +111,9 @@ impl std::fmt::Display for MigrateError {
|
||||
file = file,
|
||||
latest = latest,
|
||||
)),
|
||||
Self::NoMigratorForVersion(v) => f.write_str(&crate::t!(
|
||||
"persistence.migrate.no_migrator",
|
||||
version = v,
|
||||
)),
|
||||
Self::NoMigratorForVersion(v) => {
|
||||
f.write_str(&crate::t!("persistence.migrate.no_migrator", version = v,))
|
||||
}
|
||||
Self::StepFailed { from, to, source } => f.write_str(&crate::t!(
|
||||
"persistence.migrate.step_failed",
|
||||
from = from,
|
||||
@@ -192,8 +194,11 @@ pub fn migrate_to_latest(
|
||||
|
||||
// Write the .bak before any transformation runs so a
|
||||
// mid-migration crash leaves the original recoverable.
|
||||
let bak_path =
|
||||
project_path.join(format!("{}.v{}.bak", crate::project::PROJECT_YAML, file_version));
|
||||
let bak_path = project_path.join(format!(
|
||||
"{}.v{}.bak",
|
||||
crate::project::PROJECT_YAML,
|
||||
file_version
|
||||
));
|
||||
std::fs::write(&bak_path, body).map_err(|source| MigrateError::Io {
|
||||
path: bak_path.clone(),
|
||||
source,
|
||||
@@ -214,8 +219,8 @@ pub fn migrate_to_latest(
|
||||
// Sanity: the new body must declare the next version.
|
||||
// If a migrator forgets to bump, we'd loop endlessly
|
||||
// through the chain — catch it here.
|
||||
let advertised = read_version(&next_body)
|
||||
.map_err(|e| MigrateError::BadOutput(e.to_string()))?;
|
||||
let advertised =
|
||||
read_version(&next_body).map_err(|e| MigrateError::BadOutput(e.to_string()))?;
|
||||
if advertised != v + 1 {
|
||||
return Err(MigrateError::BadOutput(format!(
|
||||
"v{v}→v{} migrator left version field at {advertised}",
|
||||
@@ -281,9 +286,8 @@ fn read_version(body: &str) -> Result<u32, MigrateError> {
|
||||
struct VersionOnly {
|
||||
version: u32,
|
||||
}
|
||||
let v: VersionOnly = serde_norway::from_str(body).map_err(|e| {
|
||||
MigrateError::VersionParse(e.to_string())
|
||||
})?;
|
||||
let v: VersionOnly =
|
||||
serde_norway::from_str(body).map_err(|e| MigrateError::VersionParse(e.to_string()))?;
|
||||
Ok(v.version)
|
||||
}
|
||||
|
||||
@@ -309,12 +313,8 @@ mod tests {
|
||||
#[test]
|
||||
fn no_migration_runs_when_body_already_latest() {
|
||||
let tmp = tempdir();
|
||||
let outcome = migrate_to_latest(
|
||||
&v1_body(),
|
||||
&MigratorRegistry::production(),
|
||||
tmp.path(),
|
||||
)
|
||||
.unwrap();
|
||||
let outcome =
|
||||
migrate_to_latest(&v1_body(), &MigratorRegistry::production(), tmp.path()).unwrap();
|
||||
assert_eq!(outcome.body, v1_body());
|
||||
assert_eq!(outcome.migrated_from, None);
|
||||
// No .bak written when nothing migrated.
|
||||
@@ -328,7 +328,13 @@ mod tests {
|
||||
let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp"))
|
||||
.expect_err("must reject");
|
||||
assert!(
|
||||
matches!(err, MigrateError::NewerThanSupported { file: 99, latest: 1 }),
|
||||
matches!(
|
||||
err,
|
||||
MigrateError::NewerThanSupported {
|
||||
file: 99,
|
||||
latest: 1
|
||||
}
|
||||
),
|
||||
"got: {err:?}",
|
||||
);
|
||||
}
|
||||
@@ -366,12 +372,7 @@ mod tests {
|
||||
#[test]
|
||||
fn migrate_runs_chain_and_writes_bak() {
|
||||
let tmp = tempdir();
|
||||
let outcome = migrate_to_latest(
|
||||
&v1_body(),
|
||||
®istry_with_v1_to_v2(),
|
||||
tmp.path(),
|
||||
)
|
||||
.unwrap();
|
||||
let outcome = migrate_to_latest(&v1_body(), ®istry_with_v1_to_v2(), tmp.path()).unwrap();
|
||||
assert_eq!(outcome.migrated_from, Some(1));
|
||||
assert!(outcome.body.contains("version: 2"));
|
||||
let bak = tmp.path().join("project.yaml.v1.bak");
|
||||
@@ -396,11 +397,8 @@ mod tests {
|
||||
let tmp = tempdir();
|
||||
let yaml_path = tmp.path().join("project.yaml");
|
||||
std::fs::write(&yaml_path, v1_body()).unwrap();
|
||||
let outcome = ensure_project_yaml_migrated(
|
||||
tmp.path(),
|
||||
&MigratorRegistry::production(),
|
||||
)
|
||||
.unwrap();
|
||||
let outcome =
|
||||
ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
|
||||
assert_eq!(outcome.migrated_from, None);
|
||||
// File unchanged.
|
||||
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
|
||||
@@ -413,36 +411,32 @@ mod tests {
|
||||
let tmp = tempdir();
|
||||
let yaml_path = tmp.path().join("project.yaml");
|
||||
std::fs::write(&yaml_path, v1_body()).unwrap();
|
||||
let outcome = ensure_project_yaml_migrated(
|
||||
tmp.path(),
|
||||
®istry_with_v1_to_v2(),
|
||||
)
|
||||
.unwrap();
|
||||
let outcome = ensure_project_yaml_migrated(tmp.path(), ®istry_with_v1_to_v2()).unwrap();
|
||||
assert_eq!(outcome.migrated_from, Some(1));
|
||||
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
|
||||
assert!(on_disk.contains("version: 2"), "got: {on_disk}");
|
||||
let bak = tmp.path().join("project.yaml.v1.bak");
|
||||
assert!(bak.exists());
|
||||
assert!(std::fs::read_to_string(&bak).unwrap().contains("version: 1"));
|
||||
assert!(
|
||||
std::fs::read_to_string(&bak)
|
||||
.unwrap()
|
||||
.contains("version: 1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_yaml_migrated_handles_missing_yaml() {
|
||||
let tmp = tempdir();
|
||||
// No project.yaml exists.
|
||||
let outcome = ensure_project_yaml_migrated(
|
||||
tmp.path(),
|
||||
&MigratorRegistry::production(),
|
||||
)
|
||||
.unwrap();
|
||||
let outcome =
|
||||
ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
|
||||
assert_eq!(outcome.migrated_from, None);
|
||||
assert!(outcome.body.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrator_that_returns_internal_error_propagates() {
|
||||
let bad: MigrateFn =
|
||||
|_| Err(MigrateError::VersionParse("simulated".to_string()));
|
||||
let bad: MigrateFn = |_| Err(MigrateError::VersionParse("simulated".to_string()));
|
||||
let registry = MigratorRegistry {
|
||||
migrators: vec![bad],
|
||||
};
|
||||
|
||||
+15
-15
@@ -368,8 +368,7 @@ impl Persistence {
|
||||
path: data_dir.clone(),
|
||||
source,
|
||||
})?;
|
||||
let body =
|
||||
csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode {
|
||||
let body = csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode {
|
||||
kind: "CSV",
|
||||
path: data_dir.join(format!("{}.csv", table.name)),
|
||||
message,
|
||||
@@ -406,11 +405,8 @@ impl Persistence {
|
||||
) -> Result<(), PersistenceError> {
|
||||
let path = self.project_path.join(HISTORY_LOG);
|
||||
let status = history::status_token(history::STATUS_OK, advanced);
|
||||
let line = history::format_record_with_status(
|
||||
command_text,
|
||||
history::utc_iso8601_now(),
|
||||
&status,
|
||||
);
|
||||
let line =
|
||||
history::format_record_with_status(command_text, history::utc_iso8601_now(), &status);
|
||||
debug!(
|
||||
len = command_text.len(),
|
||||
advanced, "persist: append ok record to history.log"
|
||||
@@ -432,11 +428,8 @@ impl Persistence {
|
||||
) -> Result<(), PersistenceError> {
|
||||
let path = self.project_path.join(HISTORY_LOG);
|
||||
let status = history::status_token(history::STATUS_ERR, advanced);
|
||||
let line = history::format_record_with_status(
|
||||
command_text,
|
||||
history::utc_iso8601_now(),
|
||||
&status,
|
||||
);
|
||||
let line =
|
||||
history::format_record_with_status(command_text, history::utc_iso8601_now(), &status);
|
||||
debug!(
|
||||
len = command_text.len(),
|
||||
advanced, "persist: append err record to history.log"
|
||||
@@ -531,8 +524,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extension_with_tmp_appends_to_existing_extension() {
|
||||
assert_eq!(extension_with_tmp(Path::new("a/b/project.yaml")), "yaml.tmp");
|
||||
assert_eq!(extension_with_tmp(Path::new("a/b/Customers.csv")), "csv.tmp");
|
||||
assert_eq!(
|
||||
extension_with_tmp(Path::new("a/b/project.yaml")),
|
||||
"yaml.tmp"
|
||||
);
|
||||
assert_eq!(
|
||||
extension_with_tmp(Path::new("a/b/Customers.csv")),
|
||||
"csv.tmp"
|
||||
);
|
||||
assert_eq!(extension_with_tmp(Path::new("a/b/lockfile")), "tmp");
|
||||
}
|
||||
|
||||
@@ -600,7 +599,8 @@ mod tests {
|
||||
fn append_history_creates_and_appends() {
|
||||
let dir = tempdir();
|
||||
let p = Persistence::new(dir.path().to_path_buf());
|
||||
p.append_history("create table Foo with pk id(serial)", false).unwrap();
|
||||
p.append_history("create table Foo with pk id(serial)", false)
|
||||
.unwrap();
|
||||
p.append_history("insert into Foo (1)", false).unwrap();
|
||||
let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap();
|
||||
let lines: Vec<&str> = body.trim_end().lines().collect();
|
||||
|
||||
+116
-34
@@ -261,7 +261,10 @@ fn needs_quoting(s: &str) -> bool {
|
||||
}
|
||||
// Scalar text that looks like a YAML keyword needs quoting
|
||||
// even if every character is safe.
|
||||
if matches!(s, "true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off") {
|
||||
if matches!(
|
||||
s,
|
||||
"true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
s.chars().any(|c| !is_safe_yaml_char(c))
|
||||
@@ -287,12 +290,13 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
|
||||
for t in raw.tables {
|
||||
let mut columns: Vec<ColumnSchema> = Vec::with_capacity(t.columns.len());
|
||||
for c in t.columns {
|
||||
let user_type = c.user_type.parse::<Type>().map_err(|_| {
|
||||
YamlError::UnknownType {
|
||||
let user_type = c
|
||||
.user_type
|
||||
.parse::<Type>()
|
||||
.map_err(|_| YamlError::UnknownType {
|
||||
table: t.name.clone(),
|
||||
column: c.name.clone(),
|
||||
raw: c.user_type.clone(),
|
||||
}
|
||||
})?;
|
||||
columns.push(ColumnSchema {
|
||||
name: c.name,
|
||||
@@ -308,7 +312,11 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
|
||||
primary_key: t.primary_key,
|
||||
columns,
|
||||
unique_constraints: t.unique_constraints,
|
||||
check_constraints: t.check_constraints.into_iter().map(TableCheck::from).collect(),
|
||||
check_constraints: t
|
||||
.check_constraints
|
||||
.into_iter()
|
||||
.map(TableCheck::from)
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
let mut relationships: Vec<RelationshipSchema> = Vec::with_capacity(raw.relationships.len());
|
||||
@@ -381,10 +389,7 @@ pub(crate) enum YamlError {
|
||||
impl std::fmt::Display for YamlError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Syntax(msg) => f.write_str(&crate::t!(
|
||||
"persistence.yaml.syntax",
|
||||
detail = msg,
|
||||
)),
|
||||
Self::Syntax(msg) => f.write_str(&crate::t!("persistence.yaml.syntax", detail = msg,)),
|
||||
Self::UnsupportedVersion(v) => f.write_str(&crate::t!(
|
||||
"persistence.yaml.unsupported_version",
|
||||
version = v,
|
||||
@@ -395,10 +400,9 @@ impl std::fmt::Display for YamlError {
|
||||
column = column,
|
||||
raw = raw,
|
||||
)),
|
||||
Self::UnknownAction(raw) => f.write_str(&crate::t!(
|
||||
"persistence.yaml.unknown_action",
|
||||
raw = raw,
|
||||
)),
|
||||
Self::UnknownAction(raw) => {
|
||||
f.write_str(&crate::t!("persistence.yaml.unknown_action", raw = raw,))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -545,8 +549,22 @@ mod tests {
|
||||
name: "Customers".to_string(),
|
||||
primary_key: vec!["id".to_string()],
|
||||
columns: vec![
|
||||
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None },
|
||||
ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None, check: None },
|
||||
ColumnSchema {
|
||||
name: "id".to_string(),
|
||||
user_type: Type::Serial,
|
||||
unique: false,
|
||||
not_null: false,
|
||||
default: None,
|
||||
check: None,
|
||||
},
|
||||
ColumnSchema {
|
||||
name: "Name".to_string(),
|
||||
user_type: Type::Text,
|
||||
unique: false,
|
||||
not_null: false,
|
||||
default: None,
|
||||
check: None,
|
||||
},
|
||||
],
|
||||
unique_constraints: Vec::new(),
|
||||
check_constraints: Vec::new(),
|
||||
@@ -555,8 +573,22 @@ mod tests {
|
||||
name: "Orders".to_string(),
|
||||
primary_key: vec!["id".to_string()],
|
||||
columns: vec![
|
||||
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None },
|
||||
ColumnSchema { name: "CustId".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
|
||||
ColumnSchema {
|
||||
name: "id".to_string(),
|
||||
user_type: Type::Serial,
|
||||
unique: false,
|
||||
not_null: false,
|
||||
default: None,
|
||||
check: None,
|
||||
},
|
||||
ColumnSchema {
|
||||
name: "CustId".to_string(),
|
||||
user_type: Type::Int,
|
||||
unique: false,
|
||||
not_null: false,
|
||||
default: None,
|
||||
check: None,
|
||||
},
|
||||
],
|
||||
unique_constraints: Vec::new(),
|
||||
check_constraints: Vec::new(),
|
||||
@@ -798,15 +830,33 @@ indexes:
|
||||
name: "T".to_string(),
|
||||
primary_key: vec![],
|
||||
columns: vec![
|
||||
ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
|
||||
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
|
||||
ColumnSchema { name: "c".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
|
||||
ColumnSchema {
|
||||
name: "a".to_string(),
|
||||
user_type: Type::Int,
|
||||
unique: false,
|
||||
not_null: false,
|
||||
default: None,
|
||||
check: None,
|
||||
},
|
||||
ColumnSchema {
|
||||
name: "b".to_string(),
|
||||
user_type: Type::Int,
|
||||
unique: false,
|
||||
not_null: false,
|
||||
default: None,
|
||||
check: None,
|
||||
},
|
||||
ColumnSchema {
|
||||
name: "c".to_string(),
|
||||
user_type: Type::Int,
|
||||
unique: false,
|
||||
not_null: false,
|
||||
default: None,
|
||||
check: None,
|
||||
},
|
||||
],
|
||||
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
|
||||
check_constraints: vec![
|
||||
TableCheck::unnamed("a < b"),
|
||||
TableCheck::unnamed("b < c"),
|
||||
],
|
||||
check_constraints: vec![TableCheck::unnamed("a < b"), TableCheck::unnamed("b < c")],
|
||||
}],
|
||||
relationships: vec![],
|
||||
indexes: vec![],
|
||||
@@ -830,12 +880,29 @@ indexes:
|
||||
name: "T".to_string(),
|
||||
primary_key: vec!["id".to_string()],
|
||||
columns: vec![
|
||||
ColumnSchema { name: "id".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
|
||||
ColumnSchema { name: "qty".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
|
||||
ColumnSchema {
|
||||
name: "id".to_string(),
|
||||
user_type: Type::Int,
|
||||
unique: false,
|
||||
not_null: false,
|
||||
default: None,
|
||||
check: None,
|
||||
},
|
||||
ColumnSchema {
|
||||
name: "qty".to_string(),
|
||||
user_type: Type::Int,
|
||||
unique: false,
|
||||
not_null: false,
|
||||
default: None,
|
||||
check: None,
|
||||
},
|
||||
],
|
||||
unique_constraints: vec![],
|
||||
check_constraints: vec![
|
||||
TableCheck { name: Some("qty_positive".to_string()), expr: "qty >= 0".to_string() },
|
||||
TableCheck {
|
||||
name: Some("qty_positive".to_string()),
|
||||
expr: "qty >= 0".to_string(),
|
||||
},
|
||||
TableCheck::unnamed("qty < 1000"),
|
||||
],
|
||||
}],
|
||||
@@ -844,7 +911,10 @@ indexes:
|
||||
};
|
||||
let body = serialize_schema(&snap);
|
||||
let parsed = parse_schema(&body).expect("parse schema");
|
||||
assert_eq!(parsed, snap, "named + unnamed table-CHECKs survive the yaml round-trip");
|
||||
assert_eq!(
|
||||
parsed, snap,
|
||||
"named + unnamed table-CHECKs survive the yaml round-trip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -968,8 +1038,22 @@ relationships:
|
||||
name: "Items".to_string(),
|
||||
primary_key: vec!["a".to_string(), "b".to_string()],
|
||||
columns: vec![
|
||||
ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
|
||||
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
|
||||
ColumnSchema {
|
||||
name: "a".to_string(),
|
||||
user_type: Type::Int,
|
||||
unique: false,
|
||||
not_null: false,
|
||||
default: None,
|
||||
check: None,
|
||||
},
|
||||
ColumnSchema {
|
||||
name: "b".to_string(),
|
||||
user_type: Type::Int,
|
||||
unique: false,
|
||||
not_null: false,
|
||||
default: None,
|
||||
check: None,
|
||||
},
|
||||
],
|
||||
unique_constraints: Vec::new(),
|
||||
check_constraints: Vec::new(),
|
||||
@@ -1019,12 +1103,10 @@ relationships:
|
||||
let absent = "version: 1\nproject:\n created_at: x\ntables: []\n";
|
||||
assert_eq!(parse_stored_mode(absent), None);
|
||||
|
||||
let explicit_simple =
|
||||
"version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n";
|
||||
let explicit_simple = "version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n";
|
||||
assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple));
|
||||
|
||||
let advanced =
|
||||
"version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
|
||||
let advanced = "version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
|
||||
assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced));
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -170,7 +170,10 @@ fn local_hostname() -> String {
|
||||
/// Uses `sysinfo` to query the OS process table.
|
||||
fn pid_is_alive(pid: u32) -> bool {
|
||||
let mut sys = System::new();
|
||||
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), true);
|
||||
sys.refresh_processes(
|
||||
sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]),
|
||||
true,
|
||||
);
|
||||
sys.process(Pid::from_u32(pid)).is_some()
|
||||
}
|
||||
|
||||
@@ -211,7 +214,10 @@ mod tests {
|
||||
// The first lock writes our own PID; a second attempt
|
||||
// should refuse because the PID is alive on this host.
|
||||
let err = Lock::acquire(dir.path()).expect_err("should refuse second acquisition");
|
||||
assert!(matches!(err, LockError::AlreadyHeld { .. }), "unexpected: {err:?}");
|
||||
assert!(
|
||||
matches!(err, LockError::AlreadyHeld { .. }),
|
||||
"unexpected: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+25
-26
@@ -78,10 +78,7 @@ pub fn read_last_project(data_root: &Path) -> std::io::Result<Option<PathBuf>> {
|
||||
/// a moved/deleted directory is the kind of error `--resume`
|
||||
/// is supposed to surface clearly, not paper over by
|
||||
/// resolving symlinks at write time.
|
||||
pub fn write_last_project(
|
||||
data_root: &Path,
|
||||
project_path: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
pub fn write_last_project(data_root: &Path, project_path: &Path) -> std::io::Result<()> {
|
||||
fs::create_dir_all(data_root)?;
|
||||
let final_path = data_root.join(LAST_PROJECT_FILE);
|
||||
let tmp_path = data_root.join(format!("{LAST_PROJECT_FILE}.tmp"));
|
||||
@@ -108,9 +105,8 @@ pub fn resolve_data_root(override_dir: Option<&Path>) -> Result<PathBuf, Project
|
||||
if let Some(p) = override_dir {
|
||||
return Ok(p.to_path_buf());
|
||||
}
|
||||
let dirs = ProjectDirs::from("", "", "rdbms-playground").ok_or(
|
||||
ProjectError::DataRootUnavailable,
|
||||
)?;
|
||||
let dirs =
|
||||
ProjectDirs::from("", "", "rdbms-playground").ok_or(ProjectError::DataRootUnavailable)?;
|
||||
Ok(dirs.data_dir().to_path_buf())
|
||||
}
|
||||
|
||||
@@ -255,21 +251,16 @@ pub enum ProjectError {
|
||||
impl std::fmt::Display for ProjectError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::DataRootUnavailable => {
|
||||
f.write_str(&crate::t!("project.data_root_unavailable"))
|
||||
Self::DataRootUnavailable => f.write_str(&crate::t!("project.data_root_unavailable")),
|
||||
Self::PathNotFound(p) => {
|
||||
f.write_str(&crate::t!("project.path_not_found", path = p.display(),))
|
||||
}
|
||||
Self::NotAProject(p) => {
|
||||
f.write_str(&crate::t!("project.not_a_project", path = p.display(),))
|
||||
}
|
||||
Self::AlreadyExists(p) => {
|
||||
f.write_str(&crate::t!("project.already_exists", path = p.display(),))
|
||||
}
|
||||
Self::PathNotFound(p) => f.write_str(&crate::t!(
|
||||
"project.path_not_found",
|
||||
path = p.display(),
|
||||
)),
|
||||
Self::NotAProject(p) => f.write_str(&crate::t!(
|
||||
"project.not_a_project",
|
||||
path = p.display(),
|
||||
)),
|
||||
Self::AlreadyExists(p) => f.write_str(&crate::t!(
|
||||
"project.already_exists",
|
||||
path = p.display(),
|
||||
)),
|
||||
Self::Io { path, source } => f.write_str(&crate::t!(
|
||||
"project.io",
|
||||
path = path.display(),
|
||||
@@ -609,8 +600,7 @@ pub fn safely_delete_temp_project(
|
||||
// 2. Canonicalize for the containment check. We do this
|
||||
// only after the symlink-at-top check so we can't be
|
||||
// tricked by a top-level symlink.
|
||||
let project_canon =
|
||||
fs::canonicalize(project_path).map_err(|source| SafeDeleteError::Io {
|
||||
let project_canon = fs::canonicalize(project_path).map_err(|source| SafeDeleteError::Io {
|
||||
path: project_path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
@@ -848,7 +838,10 @@ mod tests {
|
||||
assert!(gi.contains("/playground.db"));
|
||||
assert!(gi.contains("/.rdbms-playground.lock"));
|
||||
assert!(gi.contains("/.snapshots/"), "undo ring should be ignored");
|
||||
assert!(!gi.contains("history.log"), "history.log should NOT be ignored");
|
||||
assert!(
|
||||
!gi.contains("history.log"),
|
||||
"history.log should NOT be ignored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -890,7 +883,10 @@ mod tests {
|
||||
let target = tmp.path().join("MyProject");
|
||||
fs::create_dir(&target).unwrap();
|
||||
let err = Project::create_named(&target).expect_err("must refuse");
|
||||
assert!(matches!(err, ProjectError::AlreadyExists(_)), "got: {err:?}");
|
||||
assert!(
|
||||
matches!(err, ProjectError::AlreadyExists(_)),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -962,7 +958,10 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let read_back = read_last_project(tmp.path()).unwrap();
|
||||
assert_eq!(read_back, Some(std::path::PathBuf::from("/tmp/some/project")));
|
||||
assert_eq!(
|
||||
read_back,
|
||||
Some(std::path::PathBuf::from("/tmp/some/project"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+29
-14
@@ -17,8 +17,8 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use rand::seq::IndexedRandom;
|
||||
use rand::Rng;
|
||||
use rand::seq::IndexedRandom;
|
||||
|
||||
const WORDLIST: &str = include_str!("wordlist.txt");
|
||||
const MAX_COLLISION_RETRIES: usize = 100;
|
||||
@@ -41,10 +41,9 @@ pub enum NamingError {
|
||||
impl std::fmt::Display for NamingError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::WordlistTooSmall(n) => f.write_str(&crate::t!(
|
||||
"project.naming.wordlist_too_small",
|
||||
count = n,
|
||||
)),
|
||||
Self::WordlistTooSmall(n) => {
|
||||
f.write_str(&crate::t!("project.naming.wordlist_too_small", count = n,))
|
||||
}
|
||||
Self::TooManyCollisions(n) => f.write_str(&crate::t!(
|
||||
"project.naming.too_many_collisions",
|
||||
attempts = n,
|
||||
@@ -189,10 +188,9 @@ impl std::fmt::Display for UserNameError {
|
||||
match self {
|
||||
Self::Empty => f.write_str(&crate::t!("project.user_name.empty")),
|
||||
Self::LeadingDot => f.write_str(&crate::t!("project.user_name.leading_dot")),
|
||||
Self::InvalidChar(c) => f.write_str(&crate::t!(
|
||||
"project.user_name.invalid_char",
|
||||
ch = c,
|
||||
)),
|
||||
Self::InvalidChar(c) => {
|
||||
f.write_str(&crate::t!("project.user_name.invalid_char", ch = c,))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,14 +207,22 @@ mod tests {
|
||||
#[test]
|
||||
fn wordlist_has_enough_entries() {
|
||||
let pool = words();
|
||||
assert!(pool.len() >= 100, "wordlist suspiciously small: {} entries", pool.len());
|
||||
assert!(
|
||||
pool.len() >= 100,
|
||||
"wordlist suspiciously small: {} entries",
|
||||
pool.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wordlist_has_no_duplicates() {
|
||||
let pool = words();
|
||||
let unique: std::collections::HashSet<_> = pool.iter().collect();
|
||||
assert_eq!(unique.len(), pool.len(), "wordlist contains duplicate entries");
|
||||
assert_eq!(
|
||||
unique.len(),
|
||||
pool.len(),
|
||||
"wordlist contains duplicate entries"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -301,9 +307,18 @@ mod tests {
|
||||
assert!(validate_user_name("project.v2").is_ok());
|
||||
|
||||
assert_eq!(validate_user_name(""), Err(UserNameError::Empty));
|
||||
assert_eq!(validate_user_name(".hidden"), Err(UserNameError::LeadingDot));
|
||||
assert!(matches!(validate_user_name("a/b"), Err(UserNameError::InvalidChar('/'))));
|
||||
assert!(matches!(validate_user_name("a b"), Err(UserNameError::InvalidChar(' '))));
|
||||
assert_eq!(
|
||||
validate_user_name(".hidden"),
|
||||
Err(UserNameError::LeadingDot)
|
||||
);
|
||||
assert!(matches!(
|
||||
validate_user_name("a/b"),
|
||||
Err(UserNameError::InvalidChar('/'))
|
||||
));
|
||||
assert!(matches!(
|
||||
validate_user_name("a b"),
|
||||
Err(UserNameError::InvalidChar(' '))
|
||||
));
|
||||
}
|
||||
|
||||
fn tempdir() -> tempfile::TempDir {
|
||||
|
||||
@@ -129,7 +129,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn strips_date_prefix_from_temp_project_names() {
|
||||
assert_eq!(prettify("20260507-water-buffalo-skating"), "Water Buffalo Skating");
|
||||
assert_eq!(
|
||||
prettify("20260507-water-buffalo-skating"),
|
||||
"Water Buffalo Skating"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -205,6 +208,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn handles_mixed_separators_and_case() {
|
||||
assert_eq!(prettify("MyTeam_lessonPlan-2026"), "My Team Lesson Plan 2026");
|
||||
assert_eq!(
|
||||
prettify("MyTeam_lessonPlan-2026"),
|
||||
"My Team Lesson Plan 2026"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+112
-136
@@ -36,8 +36,8 @@ use crate::db::{
|
||||
use crate::dsl::command::{
|
||||
Constraint, ConstraintKind, IndexSelector, RelationshipSelector, TableConstraint,
|
||||
};
|
||||
use crate::dsl::{AlterTableAction, ChangeColumnMode, Command, ColumnSpec};
|
||||
use crate::dsl::walker::Severity;
|
||||
use crate::dsl::{AlterTableAction, ChangeColumnMode, ColumnSpec, Command};
|
||||
use crate::event::AppEvent;
|
||||
use crate::project::{
|
||||
Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir,
|
||||
@@ -130,8 +130,7 @@ pub async fn run(args: Args) -> Result<()> {
|
||||
// to it for `new` (creates a temp) and `load` (lists
|
||||
// projects). We can't easily recover this from the
|
||||
// Project alone, so we keep it ourselves.
|
||||
let data_root = resolve_data_root(args.data_dir.as_deref())
|
||||
.context("resolve data root")?;
|
||||
let data_root = resolve_data_root(args.data_dir.as_deref()).context("resolve data root")?;
|
||||
|
||||
// Resolve the initial project path: --resume reads it from
|
||||
// <data-root>/last_project; otherwise an explicit positional
|
||||
@@ -143,17 +142,12 @@ pub async fn run(args: Args) -> Result<()> {
|
||||
// terminal so the message lands directly in the user's
|
||||
// shell.
|
||||
let initial_path: Option<PathBuf> = if args.resume {
|
||||
match read_last_project(&data_root)
|
||||
.context("read last_project")?
|
||||
{
|
||||
match read_last_project(&data_root).context("read last_project")? {
|
||||
Some(p) if p.exists() => Some(p),
|
||||
Some(p) => {
|
||||
eprintln!(
|
||||
"rdbms-playground: {}",
|
||||
crate::t!(
|
||||
"project.resume_recorded_missing",
|
||||
path = p.display(),
|
||||
),
|
||||
crate::t!("project.resume_recorded_missing", path = p.display(),),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -488,19 +482,15 @@ async fn run_loop(
|
||||
// Best-effort — a failure to record a failure must
|
||||
// never escalate a user error into a fatal, so the
|
||||
// result is logged and ignored.
|
||||
if let Err(e) = crate::persistence::Persistence::new(
|
||||
session.project().path().to_path_buf(),
|
||||
)
|
||||
if let Err(e) =
|
||||
crate::persistence::Persistence::new(session.project().path().to_path_buf())
|
||||
.append_history_failure(&source, advanced)
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to journal err record (ignored)");
|
||||
}
|
||||
}
|
||||
Action::PrepareRebuild => {
|
||||
spawn_prepare_rebuild(
|
||||
session.project().path().to_path_buf(),
|
||||
event_tx.clone(),
|
||||
);
|
||||
spawn_prepare_rebuild(session.project().path().to_path_buf(), event_tx.clone());
|
||||
}
|
||||
Action::Rebuild { source } => {
|
||||
spawn_rebuild(
|
||||
@@ -671,8 +661,8 @@ async fn run_loop(
|
||||
// mutually exclusive (one needs an unmodified temp, the
|
||||
// other anything else).
|
||||
let project_at_quit = session.project.as_ref();
|
||||
let cleanup_on_quit: Option<std::path::PathBuf> = project_at_quit
|
||||
.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
|
||||
let cleanup_on_quit: Option<std::path::PathBuf> =
|
||||
project_at_quit.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
|
||||
let resume_target_on_quit: Option<std::path::PathBuf> = project_at_quit
|
||||
.filter(|p| !p.is_unmodified_temp())
|
||||
.map(|p| p.path().to_path_buf());
|
||||
@@ -831,7 +821,10 @@ async fn perform_switch(
|
||||
Some(p)
|
||||
}
|
||||
SwitchRequest::NewTemp => None,
|
||||
SwitchRequest::Import { zip_path, as_target } => {
|
||||
SwitchRequest::Import {
|
||||
zip_path,
|
||||
as_target,
|
||||
} => {
|
||||
if !zip_path.exists() {
|
||||
return Err(crate::t!(
|
||||
"project.import_zip_missing",
|
||||
@@ -840,8 +833,7 @@ async fn perform_switch(
|
||||
}
|
||||
// Validate the zip up front so we don't drop the
|
||||
// current project for an unimportable file.
|
||||
let inspection = crate::archive::inspect_zip(zip_path)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let inspection = crate::archive::inspect_zip(zip_path).map_err(|e| e.to_string())?;
|
||||
let resolved = resolve_import_destination(
|
||||
as_target.as_deref(),
|
||||
&inspection.top_folder,
|
||||
@@ -856,16 +848,19 @@ async fn perform_switch(
|
||||
// state matches the in-memory db).
|
||||
if let SwitchRequest::SaveAs { .. } = &req {
|
||||
let src = session.project().path().to_path_buf();
|
||||
let dst = resolved_target.as_ref().expect("SaveAs has resolved target");
|
||||
let dst = resolved_target
|
||||
.as_ref()
|
||||
.expect("SaveAs has resolved target");
|
||||
copy_project(&src, dst).map_err(|e| e.to_string())?;
|
||||
}
|
||||
// For Import: extract the zip into the resolved target.
|
||||
// We do this *before* dropping the current project so
|
||||
// a failure here leaves the user where they were.
|
||||
if let SwitchRequest::Import { zip_path, .. } = &req {
|
||||
let dst = resolved_target.as_ref().expect("Import has resolved target");
|
||||
let inspection = crate::archive::inspect_zip(zip_path)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let dst = resolved_target
|
||||
.as_ref()
|
||||
.expect("Import has resolved target");
|
||||
let inspection = crate::archive::inspect_zip(zip_path).map_err(|e| e.to_string())?;
|
||||
crate::archive::extract_into(zip_path, dst, &inspection.top_folder)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -874,10 +869,10 @@ async fn perform_switch(
|
||||
// we drop it: if it was an unmodified empty temp, we
|
||||
// delete its directory after the switch so the data dir
|
||||
// doesn't accumulate empty scratch projects.
|
||||
let outgoing_cleanup_path: Option<std::path::PathBuf> =
|
||||
session.project.as_ref().and_then(|p| {
|
||||
p.is_unmodified_temp().then(|| p.path().to_path_buf())
|
||||
});
|
||||
let outgoing_cleanup_path: Option<std::path::PathBuf> = session
|
||||
.project
|
||||
.as_ref()
|
||||
.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
|
||||
|
||||
// Drop current project + database BEFORE opening the new
|
||||
// ones, releasing the old lock and stopping the old
|
||||
@@ -954,9 +949,7 @@ async fn perform_switch(
|
||||
let new_database =
|
||||
Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !db_existed
|
||||
&& let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await
|
||||
{
|
||||
if !db_existed && let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await {
|
||||
return Err(e.friendly_message());
|
||||
}
|
||||
|
||||
@@ -982,9 +975,7 @@ async fn perform_switch(
|
||||
// fresh empty temp (a `new` command), which must not be
|
||||
// recorded (see the gate in `run()`). Write failures are
|
||||
// non-fatal.
|
||||
if new_worth_recording
|
||||
&& let Err(e) = write_last_project(&session.data_root, &new_path)
|
||||
{
|
||||
if new_worth_recording && let Err(e) = write_last_project(&session.data_root, &new_path) {
|
||||
tracing::warn!(error = %e, "could not update last_project after switch");
|
||||
}
|
||||
|
||||
@@ -1045,8 +1036,8 @@ fn spawn_export(
|
||||
event_tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
// `export` app command: journalled simple (ADR-0052).
|
||||
let _ = crate::persistence::Persistence::new(project_path.clone())
|
||||
.append_history(&source, false);
|
||||
let _ =
|
||||
crate::persistence::Persistence::new(project_path.clone()).append_history(&source, false);
|
||||
tokio::spawn(async move {
|
||||
let outcome = tokio::task::spawn_blocking(move || {
|
||||
do_export(&project_path, &project_name, &data_root, target.as_deref())
|
||||
@@ -1081,8 +1072,7 @@ fn do_export(
|
||||
}
|
||||
None => {
|
||||
std::fs::create_dir_all(data_root).map_err(|e| e.to_string())?;
|
||||
let (filename, _) =
|
||||
crate::archive::next_export_sequence(data_root, project_name)
|
||||
let (filename, _) = crate::archive::next_export_sequence(data_root, project_name)
|
||||
.map_err(|e| e.to_string())?;
|
||||
data_root.join(filename)
|
||||
}
|
||||
@@ -1143,10 +1133,7 @@ async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEve
|
||||
/// no completion. Called wherever `TablesRefreshed` is sent
|
||||
/// today; the schema cache lives on the App and feeds Tab
|
||||
/// completion for identifier slots.
|
||||
async fn refresh_schema_cache(
|
||||
database: &Database,
|
||||
event_tx: &mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
async fn refresh_schema_cache(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
|
||||
let cache = build_schema_cache(database).await;
|
||||
let _ = event_tx.send(AppEvent::SchemaCacheRefreshed(cache)).await;
|
||||
// ADR-0046 DB2: full relationship records for the sidebar panel.
|
||||
@@ -1234,10 +1221,7 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
|
||||
/// summary that the confirmation modal shows. Runs off the
|
||||
/// event loop so the brief I/O doesn't stall input handling
|
||||
/// even on slow filesystems.
|
||||
fn spawn_prepare_rebuild(
|
||||
project_path: std::path::PathBuf,
|
||||
event_tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
fn spawn_prepare_rebuild(project_path: std::path::PathBuf, event_tx: mpsc::Sender<AppEvent>) {
|
||||
tokio::spawn(async move {
|
||||
let summary = match summarize_project(&project_path) {
|
||||
Ok(s) => s,
|
||||
@@ -1317,9 +1301,7 @@ fn spawn_rebuild(
|
||||
}
|
||||
let summary = summarize_project(&project_path)
|
||||
.unwrap_or_else(|_| "rebuild complete".to_string());
|
||||
let _ = event_tx
|
||||
.send(AppEvent::RebuildSucceeded { summary })
|
||||
.await;
|
||||
let _ = event_tx.send(AppEvent::RebuildSucceeded { summary }).await;
|
||||
// Refresh the table list so the items panel
|
||||
// reflects whatever the rebuild produced.
|
||||
if let Ok(tables) = database.list_tables().await {
|
||||
@@ -1462,12 +1444,8 @@ fn spawn_dsl_dispatch(
|
||||
}
|
||||
let event = match outcome {
|
||||
Ok(CommandOutcome::Schema(description)) => {
|
||||
let schema_echo = build_schema_echo(
|
||||
&command,
|
||||
submission_mode,
|
||||
description.as_ref(),
|
||||
&lookups,
|
||||
);
|
||||
let schema_echo =
|
||||
build_schema_echo(&command, submission_mode, description.as_ref(), &lookups);
|
||||
AppEvent::DslSucceeded {
|
||||
command: command.clone(),
|
||||
description,
|
||||
@@ -1484,12 +1462,10 @@ fn spawn_dsl_dispatch(
|
||||
Ok(CommandOutcome::SchemaDropIndexSkipped) => AppEvent::DslDropIndexSkipped {
|
||||
command: command.clone(),
|
||||
},
|
||||
Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => {
|
||||
AppEvent::DslCreateIndexSkipped {
|
||||
Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => AppEvent::DslCreateIndexSkipped {
|
||||
command: command.clone(),
|
||||
name,
|
||||
}
|
||||
}
|
||||
},
|
||||
Ok(CommandOutcome::Query(data)) => {
|
||||
// ADR-0038: `show data` is the only DSL-form query that
|
||||
// echoes; its limited form orders by the table's primary
|
||||
@@ -1507,12 +1483,10 @@ fn spawn_dsl_dispatch(
|
||||
command: command.clone(),
|
||||
lines,
|
||||
},
|
||||
Ok(CommandOutcome::ShowRelationship(data)) => {
|
||||
AppEvent::DslShowRelationshipSucceeded {
|
||||
Ok(CommandOutcome::ShowRelationship(data)) => AppEvent::DslShowRelationshipSucceeded {
|
||||
command: command.clone(),
|
||||
data: data.map(|b| *b),
|
||||
}
|
||||
}
|
||||
},
|
||||
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
|
||||
command: command.clone(),
|
||||
plan,
|
||||
@@ -1568,11 +1542,8 @@ fn spawn_dsl_dispatch(
|
||||
// the covering indexes the rebuild removed — Bucket B
|
||||
// category 2, ADR-0038 §7 Slice 2b). Non-cascade falls
|
||||
// through to the pre-execution `echo` from `echo_for`.
|
||||
let cascade_echo = build_drop_column_cascade_echo(
|
||||
&command,
|
||||
submission_mode,
|
||||
&result,
|
||||
);
|
||||
let cascade_echo =
|
||||
build_drop_column_cascade_echo(&command, submission_mode, &result);
|
||||
AppEvent::DslDropColumnSucceeded {
|
||||
command: command.clone(),
|
||||
result,
|
||||
@@ -1931,12 +1902,14 @@ fn build_schema_echo(
|
||||
)])
|
||||
}
|
||||
}
|
||||
Command::DropRelationship { .. } => lookups
|
||||
Command::DropRelationship { .. } => {
|
||||
lookups
|
||||
.drop_relationship
|
||||
.as_ref()
|
||||
.map(|(name, child_table)| {
|
||||
vec![crate::echo::render_drop_relationship(name, child_table)]
|
||||
}),
|
||||
})
|
||||
}
|
||||
// `create m:n relationship` (ADR-0045): the resolved junction
|
||||
// columns/FKs only exist on the post-exec description, so the
|
||||
// teaching echo is rendered from it (not `command_to_sql`).
|
||||
@@ -1946,14 +1919,29 @@ fn build_schema_echo(
|
||||
.iter()
|
||||
.filter_map(|c| c.user_type.map(|ty| (c.name.clone(), ty)))
|
||||
.collect();
|
||||
let primary_key: Vec<String> =
|
||||
desc.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect();
|
||||
let primary_key: Vec<String> = desc
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|c| c.primary_key)
|
||||
.map(|c| c.name.clone())
|
||||
.collect();
|
||||
let foreign_keys: Vec<(Vec<String>, String, Vec<String>)> = desc
|
||||
.outbound_relationships
|
||||
.iter()
|
||||
.map(|r| (r.local_columns.clone(), r.other_table.clone(), r.other_columns.clone()))
|
||||
.map(|r| {
|
||||
(
|
||||
r.local_columns.clone(),
|
||||
r.other_table.clone(),
|
||||
r.other_columns.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
vec![crate::echo::render_create_m2n(&desc.name, &columns, &primary_key, &foreign_keys)]
|
||||
vec![crate::echo::render_create_m2n(
|
||||
&desc.name,
|
||||
&columns,
|
||||
&primary_key,
|
||||
&foreign_keys,
|
||||
)]
|
||||
}),
|
||||
// Everything else (Bucket A pure-Command, plus the no-echo Bucket C
|
||||
// variants like `Sql*` / `ShowTable`) routes through the existing
|
||||
@@ -2103,10 +2091,7 @@ async fn enrich_unique_violation(
|
||||
facts
|
||||
}
|
||||
|
||||
fn enrich_not_null_violation(
|
||||
command: &Command,
|
||||
message: &str,
|
||||
) -> crate::friendly::FailureContext {
|
||||
fn enrich_not_null_violation(command: &Command, message: &str) -> crate::friendly::FailureContext {
|
||||
let mut facts = crate::friendly::FailureContext::default();
|
||||
let Some((table, column)) = parse_qualified_target(message) else {
|
||||
return facts;
|
||||
@@ -2133,9 +2118,7 @@ async fn enrich_fk_violation(
|
||||
// schema-aware lookup so natural-order multi-value
|
||||
// INSERT (which `user_value_for_column` alone can't
|
||||
// resolve) gets handled too.
|
||||
let Ok((outbound, _)) =
|
||||
database.read_relationships(table.clone()).await
|
||||
else {
|
||||
let Ok((outbound, _)) = database.read_relationships(table.clone()).await else {
|
||||
return facts;
|
||||
};
|
||||
facts.table = Some(table.clone());
|
||||
@@ -2173,8 +2156,7 @@ async fn enrich_fk_violation(
|
||||
// children reference). Check inbound as a fallback.
|
||||
if facts.parent_table.is_none()
|
||||
&& matches!(command, Command::Update { .. })
|
||||
&& let Ok((_, inbound)) =
|
||||
database.read_relationships(table.clone()).await
|
||||
&& let Ok((_, inbound)) = database.read_relationships(table.clone()).await
|
||||
&& let Some(rel) = inbound.first()
|
||||
{
|
||||
facts.child_table = Some(rel.other_table.clone());
|
||||
@@ -2184,9 +2166,7 @@ async fn enrich_fk_violation(
|
||||
// Parent-side: inbound FK lookup. Surface a child
|
||||
// table that still references the row(s) being
|
||||
// deleted.
|
||||
let Ok((_, inbound)) =
|
||||
database.read_relationships(table.clone()).await
|
||||
else {
|
||||
let Ok((_, inbound)) = database.read_relationships(table.clone()).await else {
|
||||
return facts;
|
||||
};
|
||||
facts.table = Some(table.clone());
|
||||
@@ -2271,10 +2251,7 @@ async fn user_value_for_column_with_schema(
|
||||
..
|
||||
} = command
|
||||
{
|
||||
let desc = database
|
||||
.describe_table(table.to_string())
|
||||
.await
|
||||
.ok()?;
|
||||
let desc = database.describe_table(table.to_string()).await.ok()?;
|
||||
// Build the natural-order column list the same way
|
||||
// `do_insert` does: filter out serial / shortid columns
|
||||
// because the engine auto-fills them and the user's
|
||||
@@ -2285,8 +2262,7 @@ async fn user_value_for_column_with_schema(
|
||||
.filter(|c| {
|
||||
!matches!(
|
||||
c.user_type,
|
||||
Some(crate::dsl::Type::Serial)
|
||||
| Some(crate::dsl::Type::ShortId)
|
||||
Some(crate::dsl::Type::Serial) | Some(crate::dsl::Type::ShortId)
|
||||
)
|
||||
})
|
||||
.map(|c| c.name.as_str())
|
||||
@@ -2310,10 +2286,7 @@ async fn user_value_for_column_with_schema(
|
||||
&& listed_columns.is_empty()
|
||||
&& literal_rows.len() == 1
|
||||
{
|
||||
let desc = database
|
||||
.describe_table(table.to_string())
|
||||
.await
|
||||
.ok()?;
|
||||
let desc = database.describe_table(table.to_string()).await.ok()?;
|
||||
let idx = desc.columns.iter().position(|c| c.name == column)?;
|
||||
return literal_rows[0].get(idx).cloned().flatten();
|
||||
}
|
||||
@@ -2323,16 +2296,12 @@ async fn user_value_for_column_with_schema(
|
||||
/// Render a `DataResult` as a `DiagnosticTable` for the
|
||||
/// friendly-error layer's bordered renderer (ADR-0019 §7,
|
||||
/// reusing ADR-0017 §7's renderer).
|
||||
fn diagnostic_from_data_result(
|
||||
data: &DataResult,
|
||||
) -> crate::friendly::DiagnosticTable {
|
||||
use crate::output_render::{numeric_alignment_for, Alignment};
|
||||
fn diagnostic_from_data_result(data: &DataResult) -> crate::friendly::DiagnosticTable {
|
||||
use crate::output_render::{Alignment, numeric_alignment_for};
|
||||
let alignments: Vec<Alignment> = data
|
||||
.column_types
|
||||
.iter()
|
||||
.map(|t| {
|
||||
t.map_or(Alignment::Left, numeric_alignment_for)
|
||||
})
|
||||
.map(|t| t.map_or(Alignment::Left, numeric_alignment_for))
|
||||
.collect();
|
||||
let rows: Vec<Vec<String>> = data
|
||||
.rows
|
||||
@@ -2543,9 +2512,7 @@ pub async fn run_replay(
|
||||
// command, which was skipped above) — report it with the line
|
||||
// number and stop.
|
||||
let schema = build_schema_cache(database).await;
|
||||
let command = match crate::dsl::parser::parse_command_with_schema(
|
||||
&command_text, &schema,
|
||||
) {
|
||||
let command = match crate::dsl::parser::parse_command_with_schema(&command_text, &schema) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
events.push(AppEvent::ReplayFailed {
|
||||
@@ -2566,8 +2533,7 @@ pub async fn run_replay(
|
||||
// Retain a clone for failure enrichment (the command is moved into
|
||||
// dispatch). ADR-0035 Amendment 1, F2 follow-up.
|
||||
let command_for_ctx = command.clone();
|
||||
let outcome =
|
||||
execute_command_typed(database, command, command_text.clone()).await;
|
||||
let outcome = execute_command_typed(database, command, command_text.clone()).await;
|
||||
match outcome {
|
||||
Ok(_) => {
|
||||
// ADR-0052: journal the replayed line at the dispatch
|
||||
@@ -2873,7 +2839,10 @@ async fn execute_command_typed(
|
||||
.drop_constraint(table, column, ConstraintKind::NotNull, src)
|
||||
.await
|
||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||
AlterTableAction::SetColumnDefault { column, default_sql } => database
|
||||
AlterTableAction::SetColumnDefault {
|
||||
column,
|
||||
default_sql,
|
||||
} => database
|
||||
.set_column_default(table, column, default_sql, src)
|
||||
.await
|
||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||
@@ -2989,10 +2958,7 @@ async fn execute_command_typed(
|
||||
// A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031).
|
||||
// The grammar walker has already validated `sql` is in
|
||||
// the supported subset; the worker runs it as text.
|
||||
Command::Select { sql } => database
|
||||
.run_select(sql)
|
||||
.await
|
||||
.map(CommandOutcome::Query),
|
||||
Command::Select { sql } => database.run_select(sql).await.map(CommandOutcome::Query),
|
||||
// A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as-
|
||||
// text: the worker runs the validated `sql` and re-persists
|
||||
// the parsed `target_table`'s CSV. Reuses the DSL insert
|
||||
@@ -3112,12 +3078,9 @@ fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
||||
Ok(terminal)
|
||||
}
|
||||
|
||||
fn teardown_terminal(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
) -> Result<()> {
|
||||
fn teardown_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
|
||||
disable_raw_mode().context("disable raw mode")?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)
|
||||
.context("leave alternate screen")?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen).context("leave alternate screen")?;
|
||||
terminal.show_cursor().context("show cursor")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -3257,7 +3220,9 @@ mod tests {
|
||||
// Limited → ORDER BY the resolved primary key.
|
||||
assert_eq!(
|
||||
super::build_show_data_echo(&db, &limited, EffectiveMode::AdvancedPersistent).await,
|
||||
Some(vec!["SELECT * FROM Customers ORDER BY id LIMIT 5".to_string()]),
|
||||
Some(vec![
|
||||
"SELECT * FROM Customers ORDER BY id LIMIT 5".to_string()
|
||||
]),
|
||||
);
|
||||
// Simple mode → silent, gated before any lookup.
|
||||
assert_eq!(
|
||||
@@ -3288,10 +3253,10 @@ mod tests {
|
||||
async fn bucket_b_resolved_name_echoes_against_real_worker() {
|
||||
use crate::app::EffectiveMode;
|
||||
use crate::db::Database;
|
||||
use crate::dsl::Command;
|
||||
use crate::dsl::ReferentialAction;
|
||||
use crate::dsl::command::{ColumnSpec, IndexSelector, RelationshipSelector};
|
||||
use crate::dsl::types::Type;
|
||||
use crate::dsl::Command;
|
||||
|
||||
let db = Database::open(":memory:").expect("open in-memory");
|
||||
db.create_table(
|
||||
@@ -3319,7 +3284,12 @@ mod tests {
|
||||
|
||||
// --- add index (auto-named) ----------------------------------
|
||||
let desc_after_add_index = db
|
||||
.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
|
||||
.add_index(
|
||||
None,
|
||||
"Customers".to_string(),
|
||||
vec!["Email".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("add index");
|
||||
let add_idx_cmd = Command::AddIndex {
|
||||
@@ -3439,7 +3409,10 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(
|
||||
endpoints_lookups.drop_relationship,
|
||||
Some(("Customers_id_to_Orders_CustId".to_string(), "Orders".to_string())),
|
||||
Some((
|
||||
"Customers_id_to_Orders_CustId".to_string(),
|
||||
"Orders".to_string()
|
||||
)),
|
||||
"endpoints selector resolves name via child describe",
|
||||
);
|
||||
|
||||
@@ -3454,7 +3427,10 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(
|
||||
named_lookups.drop_relationship,
|
||||
Some(("Customers_id_to_Orders_CustId".to_string(), "Orders".to_string())),
|
||||
Some((
|
||||
"Customers_id_to_Orders_CustId".to_string(),
|
||||
"Orders".to_string()
|
||||
)),
|
||||
"named selector scans user tables to find the child",
|
||||
);
|
||||
|
||||
@@ -3487,10 +3463,10 @@ mod tests {
|
||||
async fn bucket_b_multi_statement_echoes_against_real_worker() {
|
||||
use crate::app::EffectiveMode;
|
||||
use crate::db::Database;
|
||||
use crate::dsl::Command;
|
||||
use crate::dsl::ReferentialAction;
|
||||
use crate::dsl::command::ColumnSpec;
|
||||
use crate::dsl::types::Type;
|
||||
use crate::dsl::Command;
|
||||
|
||||
// --- drop column --cascade -----------------------------------
|
||||
let db = Database::open(":memory:").expect("open in-memory");
|
||||
@@ -3505,7 +3481,12 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("create Customers");
|
||||
db.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
|
||||
db.add_index(
|
||||
None,
|
||||
"Customers".to_string(),
|
||||
vec!["Email".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("index Email");
|
||||
|
||||
@@ -3531,11 +3512,7 @@ mod tests {
|
||||
);
|
||||
// Simple mode → silent.
|
||||
assert!(
|
||||
super::build_drop_column_cascade_echo(
|
||||
&drop_cmd,
|
||||
EffectiveMode::Simple,
|
||||
&drop_result,
|
||||
)
|
||||
super::build_drop_column_cascade_echo(&drop_cmd, EffectiveMode::Simple, &drop_result,)
|
||||
.is_none(),
|
||||
);
|
||||
|
||||
@@ -3673,11 +3650,11 @@ mod tests {
|
||||
// switch (an unmodified temp would be cleaned up, taking its
|
||||
// project.yaml with it). Without the unload persist the
|
||||
// outgoing skeleton carries no `mode:` → `None`.
|
||||
use super::{handle_project_switch, Session, SwitchRequest};
|
||||
use super::{Session, SwitchRequest, handle_project_switch};
|
||||
use crate::db::Database;
|
||||
use crate::mode::Mode;
|
||||
use crate::persistence::Persistence;
|
||||
use crate::project::{projects_dir, Project};
|
||||
use crate::project::{Project, projects_dir};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
let data_root = tempfile::tempdir().unwrap();
|
||||
@@ -3686,8 +3663,7 @@ mod tests {
|
||||
let outgoing_path = projects.join("Outgoing");
|
||||
let outgoing = Project::create_named(&outgoing_path).unwrap();
|
||||
let db_path = outgoing.db_path();
|
||||
let persistence =
|
||||
Persistence::new(outgoing.path().to_path_buf()).with_mode(Mode::Advanced);
|
||||
let persistence = Persistence::new(outgoing.path().to_path_buf()).with_mode(Mode::Advanced);
|
||||
let database =
|
||||
Database::open_with_persistence_and_undo(&db_path, persistence, true).unwrap();
|
||||
let mut session = Session {
|
||||
|
||||
+7
-3
@@ -18,7 +18,11 @@ pub fn parse_in_check_values(check: &str, column: &str) -> Option<Vec<String>> {
|
||||
return None;
|
||||
}
|
||||
let values = extract_quoted_list(&check[paren_open..])?;
|
||||
if values.is_empty() { None } else { Some(values) }
|
||||
if values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(values)
|
||||
}
|
||||
}
|
||||
|
||||
const fn is_ident_byte(b: u8) -> bool {
|
||||
@@ -45,8 +49,8 @@ fn find_in_paren(check: &str) -> Option<(usize, usize)> {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let is_in = (b == b'i' || b == b'I')
|
||||
&& bytes.get(i + 1).is_some_and(|n| *n == b'n' || *n == b'N');
|
||||
let is_in =
|
||||
(b == b'i' || b == b'I') && bytes.get(i + 1).is_some_and(|n| *n == b'n' || *n == b'N');
|
||||
if is_in {
|
||||
let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
|
||||
let after = i + 2;
|
||||
|
||||
+127
-42
@@ -81,17 +81,22 @@ pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Val
|
||||
Generator::CurrencyAmount => currency_amount(ty, rng),
|
||||
Generator::Age => Value::Number(rng.random_range(18..=80).to_string()),
|
||||
Generator::SmallInt => Value::Number(rng.random_range(1..=100).to_string()),
|
||||
Generator::YearRecent => {
|
||||
Value::Number(rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR).to_string())
|
||||
}
|
||||
Generator::YearRecent => Value::Number(
|
||||
rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR)
|
||||
.to_string(),
|
||||
),
|
||||
Generator::YearBirth => Value::Number(
|
||||
rng.random_range((REF_YEAR - YEAR_BIRTH_MAX_AGE)..=(REF_YEAR - YEAR_BIRTH_MIN_AGE))
|
||||
.to_string(),
|
||||
),
|
||||
Generator::DateRecent => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))),
|
||||
Generator::DateAdult => {
|
||||
Value::Text(format_date(random_past_date(rng, ADULT_MIN_DAYS, ADULT_MAX_DAYS)))
|
||||
Generator::DateRecent => {
|
||||
Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS)))
|
||||
}
|
||||
Generator::DateAdult => Value::Text(format_date(random_past_date(
|
||||
rng,
|
||||
ADULT_MIN_DAYS,
|
||||
ADULT_MAX_DAYS,
|
||||
))),
|
||||
Generator::DateTimeRecent => Value::Text(random_recent_datetime(rng)),
|
||||
Generator::Boolean => Value::Bool(rng.random_range(0..2) == 1),
|
||||
Generator::PickFrom(values) if !values.is_empty() => {
|
||||
@@ -232,8 +237,7 @@ fn random_datetime_between(
|
||||
} else {
|
||||
rng.random_range(hi_s..=lo_s)
|
||||
};
|
||||
let dt = chrono::DateTime::from_timestamp(secs, 0)
|
||||
.map_or(lo, |d| d.naive_utc());
|
||||
let dt = chrono::DateTime::from_timestamp(secs, 0).map_or(lo, |d| d.naive_utc());
|
||||
dt.format("%Y-%m-%dT%H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
@@ -294,20 +298,35 @@ fn currency_amount(ty: Type, rng: &mut SeedRng) -> Value {
|
||||
// — the hand-rolled `product` generator (D9) —
|
||||
|
||||
const PRODUCT_ADJECTIVES: &[&str] = &[
|
||||
"Sleek", "Rustic", "Ergonomic", "Handcrafted", "Refined", "Modern",
|
||||
"Vintage", "Compact", "Premium", "Lightweight", "Durable", "Elegant",
|
||||
"Sturdy", "Smooth", "Gorgeous", "Intelligent", "Practical", "Awesome",
|
||||
"Incredible", "Recycled",
|
||||
"Sleek",
|
||||
"Rustic",
|
||||
"Ergonomic",
|
||||
"Handcrafted",
|
||||
"Refined",
|
||||
"Modern",
|
||||
"Vintage",
|
||||
"Compact",
|
||||
"Premium",
|
||||
"Lightweight",
|
||||
"Durable",
|
||||
"Elegant",
|
||||
"Sturdy",
|
||||
"Smooth",
|
||||
"Gorgeous",
|
||||
"Intelligent",
|
||||
"Practical",
|
||||
"Awesome",
|
||||
"Incredible",
|
||||
"Recycled",
|
||||
];
|
||||
const PRODUCT_MATERIALS: &[&str] = &[
|
||||
"Wooden", "Copper", "Granite", "Cotton", "Steel", "Leather", "Bamboo",
|
||||
"Plastic", "Ceramic", "Glass", "Concrete", "Rubber", "Bronze", "Marble",
|
||||
"Linen", "Silk", "Aluminum", "Wool", "Gold", "Carbon",
|
||||
"Wooden", "Copper", "Granite", "Cotton", "Steel", "Leather", "Bamboo", "Plastic", "Ceramic",
|
||||
"Glass", "Concrete", "Rubber", "Bronze", "Marble", "Linen", "Silk", "Aluminum", "Wool", "Gold",
|
||||
"Carbon",
|
||||
];
|
||||
const PRODUCT_NOUNS: &[&str] = &[
|
||||
"Chair", "Lamp", "Table", "Bottle", "Backpack", "Keyboard", "Mug",
|
||||
"Shoes", "Jacket", "Watch", "Wallet", "Bench", "Hat", "Gloves",
|
||||
"Towel", "Ball", "Bike", "Knife", "Pillow", "Blanket",
|
||||
"Chair", "Lamp", "Table", "Bottle", "Backpack", "Keyboard", "Mug", "Shoes", "Jacket", "Watch",
|
||||
"Wallet", "Bench", "Hat", "Gloves", "Towel", "Ball", "Bike", "Knife", "Pillow", "Blanket",
|
||||
];
|
||||
|
||||
fn product_name(rng: &mut SeedRng) -> String {
|
||||
@@ -396,7 +415,9 @@ mod tests {
|
||||
] {
|
||||
let v = gen_once(&generator, Type::Text, 3);
|
||||
match v {
|
||||
Value::Text(s) => assert!(!s.trim().is_empty(), "{generator:?} produced empty text"),
|
||||
Value::Text(s) => {
|
||||
assert!(!s.trim().is_empty(), "{generator:?} produced empty text")
|
||||
}
|
||||
other => panic!("{generator:?} produced non-text {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -405,18 +426,25 @@ mod tests {
|
||||
#[test]
|
||||
fn email_looks_like_an_email() {
|
||||
let v = gen_once(&Generator::Email, Type::Text, 11);
|
||||
let Value::Text(s) = v else { panic!("not text") };
|
||||
let Value::Text(s) = v else {
|
||||
panic!("not text")
|
||||
};
|
||||
assert!(s.contains('@'), "email should contain @: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn product_name_is_three_capitalised_words() {
|
||||
let v = gen_once(&Generator::ProductName, Type::Text, 99);
|
||||
let Value::Text(s) = v else { panic!("not text") };
|
||||
let Value::Text(s) = v else {
|
||||
panic!("not text")
|
||||
};
|
||||
let words: Vec<&str> = s.split(' ').collect();
|
||||
assert_eq!(words.len(), 3, "product name should be 3 words: {s}");
|
||||
for w in words {
|
||||
assert!(w.chars().next().unwrap().is_ascii_uppercase(), "word `{w}` not capitalised");
|
||||
assert!(
|
||||
w.chars().next().unwrap().is_ascii_uppercase(),
|
||||
"word `{w}` not capitalised"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,9 +457,14 @@ mod tests {
|
||||
let latest = reference_date();
|
||||
for _ in 0..200 {
|
||||
let v = generate_value(&Generator::DateRecent, Type::Date, &mut rng);
|
||||
let Value::Text(s) = v else { panic!("date not text") };
|
||||
let Value::Text(s) = v else {
|
||||
panic!("date not text")
|
||||
};
|
||||
let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date");
|
||||
assert!(d >= earliest && d <= latest, "date {d} outside recent window");
|
||||
assert!(
|
||||
d >= earliest && d <= latest,
|
||||
"date {d} outside recent window"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +479,9 @@ mod tests {
|
||||
.unwrap();
|
||||
for _ in 0..200 {
|
||||
let v = generate_value(&Generator::DateAdult, Type::Date, &mut rng);
|
||||
let Value::Text(s) = v else { panic!("date not text") };
|
||||
let Value::Text(s) = v else {
|
||||
panic!("date not text")
|
||||
};
|
||||
let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date");
|
||||
assert!(d >= earliest && d <= latest, "dob {d} outside adult window");
|
||||
}
|
||||
@@ -455,7 +490,9 @@ mod tests {
|
||||
#[test]
|
||||
fn datetime_is_iso_shaped() {
|
||||
let v = gen_once(&Generator::DateTimeRecent, Type::DateTime, 5);
|
||||
let Value::Text(s) = v else { panic!("not text") };
|
||||
let Value::Text(s) = v else {
|
||||
panic!("not text")
|
||||
};
|
||||
assert!(s.contains('T'), "datetime needs a T separator: {s}");
|
||||
// Parses as a naive datetime.
|
||||
chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S")
|
||||
@@ -467,11 +504,17 @@ mod tests {
|
||||
let Value::Number(int_amt) = gen_once(&Generator::CurrencyAmount, Type::Int, 4) else {
|
||||
panic!("not a number")
|
||||
};
|
||||
assert!(!int_amt.contains('.'), "int currency should be whole: {int_amt}");
|
||||
assert!(
|
||||
!int_amt.contains('.'),
|
||||
"int currency should be whole: {int_amt}"
|
||||
);
|
||||
let Value::Number(dec_amt) = gen_once(&Generator::CurrencyAmount, Type::Decimal, 4) else {
|
||||
panic!("not a number")
|
||||
};
|
||||
assert!(dec_amt.contains('.'), "decimal currency should have cents: {dec_amt}");
|
||||
assert!(
|
||||
dec_amt.contains('.'),
|
||||
"decimal currency should have cents: {dec_amt}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -494,7 +537,10 @@ mod tests {
|
||||
let Value::Text(s) = generate_value(&generator, Type::Text, &mut rng) else {
|
||||
panic!("not text")
|
||||
};
|
||||
assert!(matches!(s.as_str(), "active" | "closed"), "unexpected pick {s}");
|
||||
assert!(
|
||||
matches!(s.as_str(), "active" | "closed"),
|
||||
"unexpected pick {s}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,7 +549,10 @@ mod tests {
|
||||
let generator = Generator::PickFrom(vec!["1".into(), "2".into(), "3".into()]);
|
||||
let mut rng = make_rng(Some(6));
|
||||
let v = generate_value(&generator, Type::Int, &mut rng);
|
||||
assert!(matches!(v, Value::Number(_)), "numeric pick should be a Number: {v:?}");
|
||||
assert!(
|
||||
matches!(v, Value::Number(_)),
|
||||
"numeric pick should be a Number: {v:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -517,7 +566,10 @@ mod tests {
|
||||
panic!("YearRecent must be a Number")
|
||||
};
|
||||
let n: i32 = s.parse().unwrap();
|
||||
assert!((1950..=2025).contains(&n), "YearRecent {n} out of [1950,2025]");
|
||||
assert!(
|
||||
(1950..=2025).contains(&n),
|
||||
"YearRecent {n} out of [1950,2025]"
|
||||
);
|
||||
}
|
||||
for _ in 0..300 {
|
||||
let Value::Number(s) = generate_value(&Generator::YearBirth, Type::Int, &mut rng)
|
||||
@@ -525,7 +577,10 @@ mod tests {
|
||||
panic!("YearBirth must be a Number")
|
||||
};
|
||||
let n: i32 = s.parse().unwrap();
|
||||
assert!((1945..=2007).contains(&n), "YearBirth {n} out of [1945,2007]");
|
||||
assert!(
|
||||
(1945..=2007).contains(&n),
|
||||
"YearBirth {n} out of [1945,2007]"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,7 +598,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn int_range_stays_within_inclusive_bounds() {
|
||||
let g = Generator::Range { low: "10".into(), high: "20".into() };
|
||||
let g = Generator::Range {
|
||||
low: "10".into(),
|
||||
high: "20".into(),
|
||||
};
|
||||
let mut rng = make_rng(Some(5));
|
||||
for _ in 0..200 {
|
||||
let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else {
|
||||
@@ -556,7 +614,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn real_range_stays_within_bounds_and_has_cents() {
|
||||
let g = Generator::Range { low: "1.0".into(), high: "9.0".into() };
|
||||
let g = Generator::Range {
|
||||
low: "1.0".into(),
|
||||
high: "9.0".into(),
|
||||
};
|
||||
let mut rng = make_rng(Some(5));
|
||||
for _ in 0..200 {
|
||||
let Value::Number(s) = generate_value(&g, Type::Real, &mut rng) else {
|
||||
@@ -588,13 +649,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn reversed_bounds_are_tolerated() {
|
||||
let g = Generator::Range { low: "20".into(), high: "10".into() };
|
||||
let g = Generator::Range {
|
||||
low: "20".into(),
|
||||
high: "10".into(),
|
||||
};
|
||||
let mut rng = make_rng(Some(1));
|
||||
let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else {
|
||||
panic!("number")
|
||||
};
|
||||
let n: i64 = s.parse().unwrap();
|
||||
assert!((10..=20).contains(&n), "reversed bounds still produce in-range: {n}");
|
||||
assert!(
|
||||
(10..=20).contains(&n),
|
||||
"reversed bounds still produce in-range: {n}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -603,7 +670,10 @@ mod tests {
|
||||
assert!(range_bounds_reason(Type::Int, "1", "10").is_none());
|
||||
assert!(range_bounds_reason(Type::Real, "1.5", "9.9").is_none());
|
||||
assert!(range_bounds_reason(Type::Date, "2023-01-01", "2024-01-01").is_none());
|
||||
assert!(range_bounds_reason(Type::DateTime, "2023-01-01T00:00:00", "2024-01-01T00:00:00").is_none());
|
||||
assert!(
|
||||
range_bounds_reason(Type::DateTime, "2023-01-01T00:00:00", "2024-01-01T00:00:00")
|
||||
.is_none()
|
||||
);
|
||||
// Non-numeric bound on a numeric column.
|
||||
assert!(range_bounds_reason(Type::Int, "abc", "10").is_some());
|
||||
// A range on a text column is meaningless.
|
||||
@@ -623,14 +693,29 @@ mod tests {
|
||||
#[test]
|
||||
fn generic_fallback_matches_each_type() {
|
||||
let mut rng = make_rng(Some(0));
|
||||
assert!(matches!(generate_value(&Generator::Generic, Type::Text, &mut rng), Value::Text(_)));
|
||||
assert!(matches!(generate_value(&Generator::Generic, Type::Int, &mut rng), Value::Number(_)));
|
||||
assert!(matches!(generate_value(&Generator::Generic, Type::Bool, &mut rng), Value::Bool(_)));
|
||||
assert!(matches!(generate_value(&Generator::Generic, Type::Blob, &mut rng), Value::Null));
|
||||
assert!(matches!(
|
||||
generate_value(&Generator::Generic, Type::Text, &mut rng),
|
||||
Value::Text(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
generate_value(&Generator::Generic, Type::Int, &mut rng),
|
||||
Value::Number(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
generate_value(&Generator::Generic, Type::Bool, &mut rng),
|
||||
Value::Bool(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
generate_value(&Generator::Generic, Type::Blob, &mut rng),
|
||||
Value::Null
|
||||
));
|
||||
// shortid fallback is a valid base58 id.
|
||||
let Value::Text(sid) = generate_value(&Generator::Generic, Type::ShortId, &mut rng) else {
|
||||
panic!("shortid not text")
|
||||
};
|
||||
assert!(crate::dsl::shortid::validate(&sid).is_ok(), "invalid shortid {sid}");
|
||||
assert!(
|
||||
crate::dsl::shortid::validate(&sid).is_ok(),
|
||||
"invalid shortid {sid}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+181
-44
@@ -63,8 +63,7 @@ pub fn is_enum_ish(name: &str) -> bool {
|
||||
// `rating` / `stars` were never here. `status` stays — it is
|
||||
// deliberately left to the advisory (no built-in set).
|
||||
const ENUM_TOKENS: &[&str] = &[
|
||||
"role", "status", "state", "type", "kind", "category", "level",
|
||||
"tier", "stage", "gender",
|
||||
"role", "status", "state", "type", "kind", "category", "level", "tier", "stage", "gender",
|
||||
];
|
||||
let toks = tokens(name);
|
||||
toks.iter().any(|t| ENUM_TOKENS.contains(&t.as_str()))
|
||||
@@ -81,9 +80,7 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
|
||||
if text && (has_any(toks, &["fname", "firstname"]) || has_seq(toks, "first", "name")) {
|
||||
return Some(Generator::FirstName);
|
||||
}
|
||||
if text
|
||||
&& (has_any(toks, &["lname", "lastname", "surname"]) || has_seq(toks, "last", "name"))
|
||||
{
|
||||
if text && (has_any(toks, &["lname", "lastname", "surname"]) || has_seq(toks, "last", "name")) {
|
||||
return Some(Generator::LastName);
|
||||
}
|
||||
if text && (has_any(toks, &["username", "login", "handle"]) || has_seq(toks, "user", "name")) {
|
||||
@@ -116,7 +113,10 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
|
||||
// `province` / explicit `state_name`/`state_abbr` → a real state name.
|
||||
// Bare `state` is left to enum-ish (it usually means status), so we
|
||||
// require `province` or a `state` token paired with name/abbr.
|
||||
if text && (has_token(toks, "province") || (has_token(toks, "state") && has_any(toks, &["name", "abbr"]))) {
|
||||
if text
|
||||
&& (has_token(toks, "province")
|
||||
|| (has_token(toks, "state") && has_any(toks, &["name", "abbr"])))
|
||||
{
|
||||
return Some(Generator::StateName);
|
||||
}
|
||||
if text && has_any(toks, &["street", "address", "addr"]) {
|
||||
@@ -127,7 +127,12 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
|
||||
}
|
||||
|
||||
// — Organisation / job —
|
||||
if text && has_any(toks, &["company", "employer", "org", "organization", "organisation"]) {
|
||||
if text
|
||||
&& has_any(
|
||||
toks,
|
||||
&["company", "employer", "org", "organization", "organisation"],
|
||||
)
|
||||
{
|
||||
return Some(Generator::Company);
|
||||
}
|
||||
if text && has_any(toks, &["job", "position", "profession", "occupation"]) {
|
||||
@@ -135,7 +140,21 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
|
||||
}
|
||||
|
||||
// — Free text —
|
||||
if text && has_any(toks, &["description", "bio", "notes", "note", "summary", "comment", "comments", "about"]) {
|
||||
if text
|
||||
&& has_any(
|
||||
toks,
|
||||
&[
|
||||
"description",
|
||||
"bio",
|
||||
"notes",
|
||||
"note",
|
||||
"summary",
|
||||
"comment",
|
||||
"comments",
|
||||
"about",
|
||||
],
|
||||
)
|
||||
{
|
||||
return Some(Generator::Sentence);
|
||||
}
|
||||
if text && has_any(toks, &["url", "website", "homepage", "link"]) {
|
||||
@@ -146,7 +165,14 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
|
||||
}
|
||||
|
||||
// — Numeric —
|
||||
if numeric && has_any(toks, &["price", "amount", "cost", "salary", "balance", "total", "fee", "revenue"]) {
|
||||
if numeric
|
||||
&& has_any(
|
||||
toks,
|
||||
&[
|
||||
"price", "amount", "cost", "salary", "balance", "total", "fee", "revenue",
|
||||
],
|
||||
)
|
||||
{
|
||||
return Some(Generator::CurrencyAmount);
|
||||
}
|
||||
if numeric && has_token(toks, "age") {
|
||||
@@ -233,18 +259,50 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
|
||||
fn name_by_table_context(table: &str) -> Generator {
|
||||
let toks = tokens(table);
|
||||
const PRODUCTY: &[&str] = &[
|
||||
"product", "products", "item", "items", "good", "goods",
|
||||
"merchandise", "catalog", "catalogue", "inventory", "sku", "skus",
|
||||
"product",
|
||||
"products",
|
||||
"item",
|
||||
"items",
|
||||
"good",
|
||||
"goods",
|
||||
"merchandise",
|
||||
"catalog",
|
||||
"catalogue",
|
||||
"inventory",
|
||||
"sku",
|
||||
"skus",
|
||||
];
|
||||
const COMPANYISH: &[&str] = &[
|
||||
"company", "companies", "vendor", "vendors", "supplier",
|
||||
"suppliers", "manufacturer", "manufacturers", "brand", "brands",
|
||||
"organization", "organisation",
|
||||
"company",
|
||||
"companies",
|
||||
"vendor",
|
||||
"vendors",
|
||||
"supplier",
|
||||
"suppliers",
|
||||
"manufacturer",
|
||||
"manufacturers",
|
||||
"brand",
|
||||
"brands",
|
||||
"organization",
|
||||
"organisation",
|
||||
];
|
||||
const PERSONISH: &[&str] = &[
|
||||
"user", "users", "customer", "customers", "person", "people",
|
||||
"employee", "employees", "member", "members", "contact",
|
||||
"contacts", "author", "authors", "student", "students",
|
||||
"user",
|
||||
"users",
|
||||
"customer",
|
||||
"customers",
|
||||
"person",
|
||||
"people",
|
||||
"employee",
|
||||
"employees",
|
||||
"member",
|
||||
"members",
|
||||
"contact",
|
||||
"contacts",
|
||||
"author",
|
||||
"authors",
|
||||
"student",
|
||||
"students",
|
||||
];
|
||||
if has_any(&toks, PRODUCTY) {
|
||||
Generator::ProductName
|
||||
@@ -264,9 +322,8 @@ fn name_by_table_context(table: &str) -> Generator {
|
||||
/// before this guard; this catches structural names.
|
||||
fn is_name_false_positive(toks: &[String]) -> bool {
|
||||
const NON_PERSON: &[&str] = &[
|
||||
"file", "table", "host", "domain", "field", "class", "tag",
|
||||
"event", "path", "col", "column", "db", "schema", "index", "key",
|
||||
"page", "node", "type",
|
||||
"file", "table", "host", "domain", "field", "class", "tag", "event", "path", "col",
|
||||
"column", "db", "schema", "index", "key", "page", "node", "type",
|
||||
];
|
||||
has_any(toks, NON_PERSON) && has_any(toks, &["name", "title"])
|
||||
}
|
||||
@@ -357,9 +414,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn person_name_fields_map_to_name_generators() {
|
||||
assert_eq!(choose("users", "first_name", Type::Text), Generator::FirstName);
|
||||
assert_eq!(choose("users", "firstName", Type::Text), Generator::FirstName);
|
||||
assert_eq!(choose("users", "last_name", Type::Text), Generator::LastName);
|
||||
assert_eq!(
|
||||
choose("users", "first_name", Type::Text),
|
||||
Generator::FirstName
|
||||
);
|
||||
assert_eq!(
|
||||
choose("users", "firstName", Type::Text),
|
||||
Generator::FirstName
|
||||
);
|
||||
assert_eq!(
|
||||
choose("users", "last_name", Type::Text),
|
||||
Generator::LastName
|
||||
);
|
||||
assert_eq!(choose("users", "surname", Type::Text), Generator::LastName);
|
||||
}
|
||||
|
||||
@@ -368,9 +434,15 @@ mod tests {
|
||||
assert_eq!(choose("users", "email", Type::Text), Generator::Email);
|
||||
assert_eq!(choose("users", "work_email", Type::Text), Generator::Email);
|
||||
assert_eq!(choose("users", "username", Type::Text), Generator::Username);
|
||||
assert_eq!(choose("users", "user_name", Type::Text), Generator::Username);
|
||||
assert_eq!(
|
||||
choose("users", "user_name", Type::Text),
|
||||
Generator::Username
|
||||
);
|
||||
assert_eq!(choose("users", "phone", Type::Text), Generator::Phone);
|
||||
assert_eq!(choose("accounts", "password", Type::Text), Generator::Password);
|
||||
assert_eq!(
|
||||
choose("accounts", "password", Type::Text),
|
||||
Generator::Password
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -386,7 +458,10 @@ mod tests {
|
||||
#[test]
|
||||
fn bare_name_uses_table_context() {
|
||||
// D11 — the same column name resolves differently by table.
|
||||
assert_eq!(choose("products", "name", Type::Text), Generator::ProductName);
|
||||
assert_eq!(
|
||||
choose("products", "name", Type::Text),
|
||||
Generator::ProductName
|
||||
);
|
||||
assert_eq!(choose("items", "title", Type::Text), Generator::ProductName);
|
||||
assert_eq!(choose("users", "name", Type::Text), Generator::FullName);
|
||||
assert_eq!(choose("customers", "name", Type::Text), Generator::FullName);
|
||||
@@ -399,7 +474,10 @@ mod tests {
|
||||
fn name_false_positives_do_not_become_person_names() {
|
||||
// These must NOT resolve to a person/product name.
|
||||
assert_ne!(choose("files", "filename", Type::Text), Generator::FullName);
|
||||
assert_ne!(choose("meta", "table_name", Type::Text), Generator::FullName);
|
||||
assert_ne!(
|
||||
choose("meta", "table_name", Type::Text),
|
||||
Generator::FullName
|
||||
);
|
||||
// They fall through to a generic / non-person generator.
|
||||
assert_eq!(choose("files", "filename", Type::Text), Generator::Generic);
|
||||
}
|
||||
@@ -408,7 +486,10 @@ mod tests {
|
||||
fn numeric_name_heuristics_are_type_gated() {
|
||||
// `price` on a numeric column → currency; on text → falls through.
|
||||
assert_eq!(choose("p", "price", Type::Int), Generator::CurrencyAmount);
|
||||
assert_eq!(choose("p", "price", Type::Decimal), Generator::CurrencyAmount);
|
||||
assert_eq!(
|
||||
choose("p", "price", Type::Decimal),
|
||||
Generator::CurrencyAmount
|
||||
);
|
||||
assert_eq!(choose("p", "price", Type::Text), Generator::Generic);
|
||||
assert_eq!(choose("u", "age", Type::Int), Generator::Age);
|
||||
assert_eq!(choose("o", "quantity", Type::Int), Generator::SmallInt);
|
||||
@@ -425,8 +506,14 @@ mod tests {
|
||||
fn temporal_fields_are_bounded_and_type_gated() {
|
||||
assert_eq!(choose("u", "dob", Type::Date), Generator::DateAdult);
|
||||
assert_eq!(choose("o", "order_date", Type::Date), Generator::DateRecent);
|
||||
assert_eq!(choose("o", "created_at", Type::DateTime), Generator::DateTimeRecent);
|
||||
assert_eq!(choose("o", "timestamp", Type::DateTime), Generator::DateTimeRecent);
|
||||
assert_eq!(
|
||||
choose("o", "created_at", Type::DateTime),
|
||||
Generator::DateTimeRecent
|
||||
);
|
||||
assert_eq!(
|
||||
choose("o", "timestamp", Type::DateTime),
|
||||
Generator::DateTimeRecent
|
||||
);
|
||||
// Wrong type → not a date generator.
|
||||
assert_eq!(choose("o", "order_date", Type::Int), Generator::Generic);
|
||||
}
|
||||
@@ -440,17 +527,32 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn identifier_family_is_unique_sequential() {
|
||||
assert_eq!(choose("t", "code", Type::Text), Generator::IdentitySequential);
|
||||
assert_eq!(choose("t", "sku", Type::Text), Generator::IdentitySequential);
|
||||
assert_eq!(choose("t", "order_number", Type::Int), Generator::IdentitySequential);
|
||||
assert_eq!(choose("t", "external_id", Type::Int), Generator::IdentitySequential);
|
||||
assert_eq!(
|
||||
choose("t", "code", Type::Text),
|
||||
Generator::IdentitySequential
|
||||
);
|
||||
assert_eq!(
|
||||
choose("t", "sku", Type::Text),
|
||||
Generator::IdentitySequential
|
||||
);
|
||||
assert_eq!(
|
||||
choose("t", "order_number", Type::Int),
|
||||
Generator::IdentitySequential
|
||||
);
|
||||
assert_eq!(
|
||||
choose("t", "external_id", Type::Int),
|
||||
Generator::IdentitySequential
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_key_columns_defer_to_executor() {
|
||||
let mut spec = ColumnSpec::plain("user_id", Type::Int);
|
||||
spec.is_foreign_key = true;
|
||||
assert_eq!(choose_generator("orders", &spec), Generator::ForeignKeySample);
|
||||
assert_eq!(
|
||||
choose_generator("orders", &spec),
|
||||
Generator::ForeignKeySample
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -481,13 +583,28 @@ mod tests {
|
||||
fn year_like_int_columns_map_to_bounded_years() {
|
||||
// Issue #33: `int`-gated year heuristics. `birth`/`born`/`dob`
|
||||
// years pick the birth window; the rest a recent window.
|
||||
assert_eq!(choose("authors", "birth_year", Type::Int), Generator::YearBirth);
|
||||
assert_eq!(choose("authors", "birthYear", Type::Int), Generator::YearBirth);
|
||||
assert_eq!(
|
||||
choose("authors", "birth_year", Type::Int),
|
||||
Generator::YearBirth
|
||||
);
|
||||
assert_eq!(
|
||||
choose("authors", "birthYear", Type::Int),
|
||||
Generator::YearBirth
|
||||
);
|
||||
assert_eq!(choose("u", "year_born", Type::Int), Generator::YearBirth);
|
||||
assert_eq!(choose("books", "year", Type::Int), Generator::YearRecent);
|
||||
assert_eq!(choose("films", "release_year", Type::Int), Generator::YearRecent);
|
||||
assert_eq!(choose("books", "published", Type::Int), Generator::YearRecent);
|
||||
assert_eq!(choose("companies", "founded", Type::Int), Generator::YearRecent);
|
||||
assert_eq!(
|
||||
choose("films", "release_year", Type::Int),
|
||||
Generator::YearRecent
|
||||
);
|
||||
assert_eq!(
|
||||
choose("books", "published", Type::Int),
|
||||
Generator::YearRecent
|
||||
);
|
||||
assert_eq!(
|
||||
choose("companies", "founded", Type::Int),
|
||||
Generator::YearRecent
|
||||
);
|
||||
// Type-gated: a text `year` is not a bounded-year int.
|
||||
assert_eq!(choose("books", "year", Type::Text), Generator::Generic);
|
||||
// `year_count` is a count, not a year — the quantity rule wins.
|
||||
@@ -507,7 +624,12 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
choose("bugs", "severity", Type::Text),
|
||||
Generator::PickFrom(vec!["low".into(), "medium".into(), "high".into(), "critical".into()]),
|
||||
Generator::PickFrom(vec![
|
||||
"low".into(),
|
||||
"medium".into(),
|
||||
"high".into(),
|
||||
"critical".into()
|
||||
]),
|
||||
);
|
||||
assert_eq!(
|
||||
choose("bugs", "severity", Type::Int),
|
||||
@@ -515,11 +637,23 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
choose("reviews", "rating", Type::Int),
|
||||
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]),
|
||||
Generator::PickFrom(vec![
|
||||
"1".into(),
|
||||
"2".into(),
|
||||
"3".into(),
|
||||
"4".into(),
|
||||
"5".into()
|
||||
]),
|
||||
);
|
||||
assert_eq!(
|
||||
choose("reviews", "stars", Type::Int),
|
||||
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]),
|
||||
Generator::PickFrom(vec![
|
||||
"1".into(),
|
||||
"2".into(),
|
||||
"3".into(),
|
||||
"4".into(),
|
||||
"5".into()
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -552,7 +686,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unmatched_columns_use_type_based_fallback() {
|
||||
assert_eq!(choose("t", "some_freeform_field", Type::Text), Generator::Generic);
|
||||
assert_eq!(
|
||||
choose("t", "some_freeform_field", Type::Text),
|
||||
Generator::Generic
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+5
-2
@@ -32,7 +32,7 @@ mod vocabulary;
|
||||
pub use check::parse_in_check_values;
|
||||
pub use generators::{generate_value, range_bounds_reason};
|
||||
pub use heuristics::{choose_generator, is_enum_ish};
|
||||
pub use vocabulary::{generator_for_name, is_known_generator_prefix, KNOWN_GENERATORS};
|
||||
pub use vocabulary::{KNOWN_GENERATORS, generator_for_name, is_known_generator_prefix};
|
||||
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{RngExt, SeedableRng};
|
||||
@@ -183,7 +183,10 @@ pub enum Generator {
|
||||
/// does not parse for the column type is a friendly error), so
|
||||
/// [`generate_value`] only ever sees parseable bounds; a defensive
|
||||
/// parse failure falls back to type-based generation.
|
||||
Range { low: String, high: String },
|
||||
Range {
|
||||
low: String,
|
||||
high: String,
|
||||
},
|
||||
/// Type-based fallback (D8) when no name heuristic matches.
|
||||
Generic,
|
||||
}
|
||||
|
||||
+22
-13
@@ -192,10 +192,7 @@ mod tests {
|
||||
("tok_function", t.tok_function),
|
||||
("warning", t.warning),
|
||||
] {
|
||||
assert_ne!(
|
||||
c, t.bg,
|
||||
"{name} must contrast against bg in dark theme",
|
||||
);
|
||||
assert_ne!(c, t.bg, "{name} must contrast against bg in dark theme",);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,24 +209,36 @@ mod tests {
|
||||
("tok_function", t.tok_function),
|
||||
("warning", t.warning),
|
||||
] {
|
||||
assert_ne!(
|
||||
c, t.bg,
|
||||
"{name} must contrast against bg in light theme",
|
||||
);
|
||||
assert_ne!(c, t.bg, "{name} must contrast against bg in light theme",);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_class_color_maps_each_variant() {
|
||||
let t = Theme::dark();
|
||||
assert_eq!(t.highlight_class_color(HighlightClass::Keyword), t.tok_keyword);
|
||||
assert_eq!(t.highlight_class_color(HighlightClass::Identifier), t.tok_identifier);
|
||||
assert_eq!(
|
||||
t.highlight_class_color(HighlightClass::Keyword),
|
||||
t.tok_keyword
|
||||
);
|
||||
assert_eq!(
|
||||
t.highlight_class_color(HighlightClass::Identifier),
|
||||
t.tok_identifier
|
||||
);
|
||||
assert_eq!(t.highlight_class_color(HighlightClass::Type), t.tok_type);
|
||||
assert_eq!(t.highlight_class_color(HighlightClass::Number), t.tok_number);
|
||||
assert_eq!(t.highlight_class_color(HighlightClass::String), t.tok_string);
|
||||
assert_eq!(
|
||||
t.highlight_class_color(HighlightClass::Number),
|
||||
t.tok_number
|
||||
);
|
||||
assert_eq!(
|
||||
t.highlight_class_color(HighlightClass::String),
|
||||
t.tok_string
|
||||
);
|
||||
assert_eq!(t.highlight_class_color(HighlightClass::Punct), t.tok_punct);
|
||||
assert_eq!(t.highlight_class_color(HighlightClass::Flag), t.tok_flag);
|
||||
assert_eq!(t.highlight_class_color(HighlightClass::Function), t.tok_function);
|
||||
assert_eq!(
|
||||
t.highlight_class_color(HighlightClass::Function),
|
||||
t.tok_function
|
||||
);
|
||||
assert_eq!(t.highlight_class_color(HighlightClass::Error), t.tok_error);
|
||||
}
|
||||
|
||||
|
||||
+18
-21
@@ -87,9 +87,7 @@ pub fn static_refusal(src: Type, target: Type) -> Option<String> {
|
||||
}
|
||||
|
||||
const fn is_in_matrix(src: Type, target: Type) -> bool {
|
||||
use Type::{
|
||||
Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text,
|
||||
};
|
||||
use Type::{Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text};
|
||||
matches!(
|
||||
(src, target),
|
||||
// Always-clean transformers
|
||||
@@ -130,9 +128,7 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
|
||||
if matches!(value, Value::Null) {
|
||||
return CellOutcome::Clean(Value::Null);
|
||||
}
|
||||
use Type::{
|
||||
Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text,
|
||||
};
|
||||
use Type::{Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text};
|
||||
match (src, target) {
|
||||
// ---- Always-clean: int / serial source ----
|
||||
(Int | Serial, Real) => match value {
|
||||
@@ -179,9 +175,11 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
|
||||
(Bool, Text) => match value {
|
||||
// "true" / "false" matches the DSL boolean grammar
|
||||
// (ADR-0014 §5), not raw 0/1 stringification.
|
||||
Value::Integer(i) => CellOutcome::Clean(Value::Text(
|
||||
if *i == 0 { "false".into() } else { "true".into() },
|
||||
)),
|
||||
Value::Integer(i) => CellOutcome::Clean(Value::Text(if *i == 0 {
|
||||
"false".into()
|
||||
} else {
|
||||
"true".into()
|
||||
})),
|
||||
other => unexpected_storage("bool", other),
|
||||
},
|
||||
|
||||
@@ -369,9 +367,7 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
|
||||
}
|
||||
} else {
|
||||
CellOutcome::Incompatible {
|
||||
reason: format!(
|
||||
"`{s}` is not a datetime in `YYYY-MM-DDTHH:MM:SS` form"
|
||||
),
|
||||
reason: format!("`{s}` is not a datetime in `YYYY-MM-DDTHH:MM:SS` form"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,10 +446,7 @@ fn real_to_int(r: f64) -> CellOutcome {
|
||||
let discarded = r - r.trunc();
|
||||
CellOutcome::Lossy {
|
||||
new: Value::Integer(truncated),
|
||||
reason: format!(
|
||||
"truncated; would discard {}",
|
||||
format_real(discarded)
|
||||
),
|
||||
reason: format!("truncated; would discard {}", format_real(discarded)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -555,9 +548,7 @@ fn format_real(r: f64) -> String {
|
||||
|
||||
fn unexpected_storage(label: &str, value: &Value) -> CellOutcome {
|
||||
CellOutcome::Incompatible {
|
||||
reason: format!(
|
||||
"internal: cell stored unexpectedly for `{label}` source ({value:?})"
|
||||
),
|
||||
reason: format!("internal: cell stored unexpectedly for `{label}` source ({value:?})"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,7 +629,10 @@ mod tests {
|
||||
(Type::Date, Type::Int),
|
||||
(Type::ShortId, Type::Int),
|
||||
] {
|
||||
assert!(static_refusal(src, target).is_some(), "{src:?} -> {target:?}");
|
||||
assert!(
|
||||
static_refusal(src, target).is_some(),
|
||||
"{src:?} -> {target:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,7 +666,10 @@ mod tests {
|
||||
(Type::Bool, Type::Real),
|
||||
];
|
||||
for (s, t) in pairs {
|
||||
assert_eq!(transform_cell(s, t, &Value::Null), CellOutcome::Clean(Value::Null));
|
||||
assert_eq!(
|
||||
transform_cell(s, t, &Value::Null),
|
||||
CellOutcome::Clean(Value::Null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -196,7 +196,16 @@ fn render_badge_box(label: &str, area: Rect, above: Option<Rect>, frame: &mut Fr
|
||||
area.y + area.height - box_h - 1
|
||||
}
|
||||
};
|
||||
fill_overlay_rect(Rect { x, y, width: box_w, height: box_h }, label.to_string(), frame);
|
||||
fill_overlay_rect(
|
||||
Rect {
|
||||
x,
|
||||
y,
|
||||
width: box_w,
|
||||
height: box_h,
|
||||
},
|
||||
label.to_string(),
|
||||
frame,
|
||||
);
|
||||
}
|
||||
|
||||
/// A step-caption box inset one cell from the bottom-right of `area`
|
||||
@@ -309,7 +318,9 @@ fn render_path_entry(
|
||||
let inner_w = dialog_w.saturating_sub(4) as usize;
|
||||
let prompt_lines = wrap_lines(&m.prompt, inner_w);
|
||||
// Title + blank + prompt + blank + input box (1 row + borders) + blank + key hints.
|
||||
let dialog_h = (prompt_lines.len() as u16).saturating_add(8).min(area.height);
|
||||
let dialog_h = (prompt_lines.len() as u16)
|
||||
.saturating_add(8)
|
||||
.min(area.height);
|
||||
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
|
||||
let dialog_area = Rect {
|
||||
@@ -320,9 +331,7 @@ fn render_path_entry(
|
||||
};
|
||||
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
||||
|
||||
let title_style = Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
@@ -386,9 +395,7 @@ fn render_load_picker(
|
||||
};
|
||||
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
||||
|
||||
let title_style = Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
@@ -411,9 +418,7 @@ fn render_load_picker(
|
||||
let marker = if i == m.selected { "›" } else { " " };
|
||||
let temp_tag = if entry.is_temp { "[TEMP] " } else { "" };
|
||||
let style = if i == m.selected {
|
||||
Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg)
|
||||
};
|
||||
@@ -447,11 +452,7 @@ fn render_load_picker(
|
||||
let display_input = if *cursor == input.len() {
|
||||
format!("{input}{cursor_marker}")
|
||||
} else {
|
||||
format!(
|
||||
"{}{cursor_marker}{}",
|
||||
&input[..*cursor],
|
||||
&input[*cursor..]
|
||||
)
|
||||
format!("{}{cursor_marker}{}", &input[..*cursor], &input[*cursor..])
|
||||
};
|
||||
text_lines.push(Line::from(format!("> {display_input}")));
|
||||
text_lines.push(Line::from(""));
|
||||
@@ -500,9 +501,7 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a
|
||||
let bg = ratatui::widgets::Clear;
|
||||
frame.render_widget(bg, dialog_area);
|
||||
|
||||
let title_style = Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
@@ -524,16 +523,12 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a
|
||||
text_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"[Y]",
|
||||
Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" {} ", crate::t!("shortcut.yes"))),
|
||||
Span::styled(
|
||||
"[N]",
|
||||
Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" {} ", crate::t!("shortcut.no"))),
|
||||
Span::styled("Esc", Style::default().fg(theme.muted)),
|
||||
@@ -578,18 +573,14 @@ where
|
||||
/// dialog (issue #13): wide enough to hold the longest content
|
||||
/// line on a single row, clamped to sane bounds and the available
|
||||
/// area so a short insert no longer wraps on roomy terminals.
|
||||
fn undo_dialog_width(
|
||||
content_widths: impl IntoIterator<Item = usize>,
|
||||
area_width: u16,
|
||||
) -> u16 {
|
||||
fn undo_dialog_width(content_widths: impl IntoIterator<Item = usize>, area_width: u16) -> u16 {
|
||||
/// Floor — comfortably fits the button row plus borders.
|
||||
const MIN: u16 = 34;
|
||||
/// Ceiling for outlier (ultra-wide) terminals.
|
||||
const MAX: u16 = 100;
|
||||
let widest = content_widths.into_iter().max().unwrap_or(0);
|
||||
// +4: left/right border (2) + one padding column each side (2).
|
||||
let preferred =
|
||||
u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4);
|
||||
let preferred = u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4);
|
||||
let upper = area_width.min(MAX);
|
||||
let lower = MIN.min(upper);
|
||||
preferred.clamp(lower, upper)
|
||||
@@ -617,8 +608,7 @@ fn render_undo_confirm(
|
||||
let intro_line = format!("{intro} {}", m.command);
|
||||
// Local-time, human-formatted snapshot stamp (issue #13).
|
||||
let when_display = format_snapshot_timestamp(&m.timestamp);
|
||||
let when_line =
|
||||
crate::t!("modal.undo_confirm_when", timestamp = when_display);
|
||||
let when_line = crate::t!("modal.undo_confirm_when", timestamp = when_display);
|
||||
let prompt = crate::t!("modal.undo_confirm_prompt");
|
||||
// Reconstruct the button row purely to measure its width — the
|
||||
// styled spans are built below. Keep this in sync with them.
|
||||
@@ -681,9 +671,15 @@ fn render_undo_confirm(
|
||||
text_lines.push(Line::from(prompt));
|
||||
text_lines.push(Line::from(""));
|
||||
text_lines.push(Line::from(vec![
|
||||
Span::styled("[Y]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(
|
||||
"[Y]",
|
||||
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" {} ", crate::t!("shortcut.yes"))),
|
||||
Span::styled("[N]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(
|
||||
"[N]",
|
||||
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" {} ", crate::t!("shortcut.no"))),
|
||||
Span::styled("Esc", Style::default().fg(theme.muted)),
|
||||
Span::styled(
|
||||
@@ -811,17 +807,13 @@ fn clamp_wrapped(text: &str, width: usize, max_rows: usize) -> Vec<String> {
|
||||
|
||||
fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||
let label_style = Style::default().fg(theme.muted);
|
||||
let value_style = Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let value_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
|
||||
let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
|
||||
|
||||
let no_project = crate::t!("status.no_project");
|
||||
let display = app.project_name.as_deref().unwrap_or(no_project.as_str());
|
||||
let mut spans: Vec<Span<'_>> = vec![Span::styled(
|
||||
crate::t!("status.project_label"),
|
||||
label_style,
|
||||
)];
|
||||
let mut spans: Vec<Span<'_>> =
|
||||
vec![Span::styled(crate::t!("status.project_label"), label_style)];
|
||||
if app.project_is_temp {
|
||||
spans.push(Span::styled(
|
||||
"[TEMP] ",
|
||||
@@ -875,9 +867,7 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
|
||||
))
|
||||
.title(Span::styled(
|
||||
format!(" {} ", crate::t!("panel.tables_title")),
|
||||
Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||
|
||||
@@ -918,9 +908,7 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
|
||||
let mut lines: Vec<Line<'_>> = Vec::new();
|
||||
for name in &app.tables {
|
||||
let style = if name == highlight {
|
||||
Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg)
|
||||
};
|
||||
@@ -937,7 +925,9 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
|
||||
}
|
||||
}
|
||||
}
|
||||
let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0));
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.block(block)
|
||||
.scroll((offset as u16, 0));
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
@@ -956,9 +946,7 @@ fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_
|
||||
))
|
||||
.title(Span::styled(
|
||||
format!(" {} ", crate::t!("panel.relationships_title")),
|
||||
Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||
|
||||
@@ -992,12 +980,24 @@ fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_
|
||||
ellipsize(&rel.name, inner_w),
|
||||
name_style,
|
||||
)));
|
||||
let parent = format!(" {}.{} ->", rel.parent_table, rel.parent_columns.join(", "));
|
||||
lines.push(Line::from(Span::styled(ellipsize(&parent, inner_w), detail_style)));
|
||||
let parent = format!(
|
||||
" {}.{} ->",
|
||||
rel.parent_table,
|
||||
rel.parent_columns.join(", ")
|
||||
);
|
||||
lines.push(Line::from(Span::styled(
|
||||
ellipsize(&parent, inner_w),
|
||||
detail_style,
|
||||
)));
|
||||
let child = format!(" {}.{}", rel.child_table, rel.child_columns.join(", "));
|
||||
lines.push(Line::from(Span::styled(ellipsize(&child, inner_w), detail_style)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
ellipsize(&child, inner_w),
|
||||
detail_style,
|
||||
)));
|
||||
}
|
||||
let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0));
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.block(block)
|
||||
.scroll((offset as u16, 0));
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
@@ -1022,9 +1022,7 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(Span::styled(
|
||||
format!(" {} ", crate::t!("panel.output_title")),
|
||||
Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||
|
||||
@@ -1132,9 +1130,9 @@ const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style {
|
||||
OutputStyleClass::Neutral => Style::new().fg(theme.fg),
|
||||
OutputStyleClass::Efficient => Style::new().fg(theme.plan_efficient),
|
||||
OutputStyleClass::Expensive => Style::new().fg(theme.warning),
|
||||
OutputStyleClass::AutomaticIndex => Style::new()
|
||||
.fg(theme.warning)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
OutputStyleClass::AutomaticIndex => {
|
||||
Style::new().fg(theme.warning).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
// ADR-0038 §4 / §6: de-emphasised text — the `Executing SQL:`
|
||||
// prefix and every category-3 prose line (caveat + the
|
||||
// existing `client_side.*` notes). `theme.muted` is the
|
||||
@@ -1239,9 +1237,7 @@ fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
|
||||
&line.text[..prefix_len],
|
||||
Style::default().fg(theme.muted),
|
||||
));
|
||||
for run in
|
||||
crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced)
|
||||
{
|
||||
for run in crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced) {
|
||||
spans.push(Span::styled(
|
||||
&rest[run.byte_range.0..run.byte_range.1],
|
||||
run.style,
|
||||
@@ -1350,9 +1346,7 @@ fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
label,
|
||||
Style::default()
|
||||
.fg(mode_color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
]);
|
||||
@@ -1474,7 +1468,10 @@ fn render_input_one_row(
|
||||
if offset > 0 {
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled("<", marker)),
|
||||
Rect { width: 1, ..text_area },
|
||||
Rect {
|
||||
width: 1,
|
||||
..text_area
|
||||
},
|
||||
);
|
||||
}
|
||||
if offset + eff < line_cols {
|
||||
@@ -1536,8 +1533,16 @@ fn render_input_two_rows(
|
||||
// Overflowing both rows reserves a marker column on each row's
|
||||
// outer edge; otherwise both rows use their full text width.
|
||||
let overflow = line_cols >= capacity;
|
||||
let row0_text_w = if overflow { row0_w.saturating_sub(1) } else { row0_w };
|
||||
let row1_text_w = if overflow { row1_w.saturating_sub(1) } else { row1_w };
|
||||
let row0_text_w = if overflow {
|
||||
row0_w.saturating_sub(1)
|
||||
} else {
|
||||
row0_w
|
||||
};
|
||||
let row1_text_w = if overflow {
|
||||
row1_w.saturating_sub(1)
|
||||
} else {
|
||||
row1_w
|
||||
};
|
||||
let eff_cap = row0_text_w + row1_text_w;
|
||||
|
||||
let start = offset.min(len);
|
||||
@@ -1552,7 +1557,11 @@ fn render_input_two_rows(
|
||||
)
|
||||
};
|
||||
|
||||
let row0_x = if overflow { text_area.x + 1 } else { text_area.x };
|
||||
let row0_x = if overflow {
|
||||
text_area.x + 1
|
||||
} else {
|
||||
text_area.x
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(to_line(&window[..split])),
|
||||
Rect {
|
||||
@@ -1622,10 +1631,7 @@ fn expand_runs_to_cells(
|
||||
/// Convert `StyledRun`s into ratatui `Span`s borrowed from
|
||||
/// `input`. The end-of-input cursor sentinel (empty range) is
|
||||
/// rendered as an inverted space.
|
||||
fn runs_to_spans<'a>(
|
||||
input: &'a str,
|
||||
runs: &[crate::input_render::StyledRun],
|
||||
) -> Vec<Span<'a>> {
|
||||
fn runs_to_spans<'a>(input: &'a str, runs: &[crate::input_render::StyledRun]) -> Vec<Span<'a>> {
|
||||
runs.iter()
|
||||
.map(|r| {
|
||||
if r.byte_range.0 == r.byte_range.1 {
|
||||
@@ -1710,21 +1716,14 @@ fn resolve_hint_lines(
|
||||
}
|
||||
}
|
||||
|
||||
fn render_hint_panel(
|
||||
theme: &Theme,
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
lines: Vec<Line<'static>>,
|
||||
) {
|
||||
fn render_hint_panel(theme: &Theme, frame: &mut Frame<'_>, area: Rect, lines: Vec<Line<'static>>) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(Span::styled(
|
||||
format!(" {} ", crate::t!("panel.hint_title")),
|
||||
Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||
|
||||
@@ -1918,9 +1917,7 @@ fn status_bar_bindings(app: &App) -> Vec<(&'static str, String)> {
|
||||
}
|
||||
|
||||
fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||
let key_style = Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let key_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
|
||||
let sep_style = Style::default().fg(theme.muted);
|
||||
let label_style = Style::default().fg(theme.muted);
|
||||
let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
|
||||
@@ -2016,9 +2013,16 @@ mod tests {
|
||||
};
|
||||
let rendered = render_output_line(&line, &theme);
|
||||
// [system] tag, then the dim prefix, then ≥1 SQL spans.
|
||||
assert!(rendered.spans.len() >= 3, "tag + prefix + sql: {:?}", rendered.spans);
|
||||
assert!(
|
||||
rendered.spans.len() >= 3,
|
||||
"tag + prefix + sql: {:?}",
|
||||
rendered.spans
|
||||
);
|
||||
assert_eq!(rendered.spans[0].content.as_ref(), "[system] ");
|
||||
assert_eq!(rendered.spans[1].content.as_ref(), crate::echo::TEACHING_ECHO_LABEL);
|
||||
assert_eq!(
|
||||
rendered.spans[1].content.as_ref(),
|
||||
crate::echo::TEACHING_ECHO_LABEL
|
||||
);
|
||||
assert_eq!(
|
||||
rendered.spans[1].style.fg,
|
||||
Some(theme.muted),
|
||||
@@ -2152,17 +2156,41 @@ mod tests {
|
||||
use crate::completion::{Candidate, CandidateKind, ModeClass};
|
||||
let theme = Theme::dark();
|
||||
let items = vec![
|
||||
Candidate { text: "table".into(), kind: CandidateKind::Keyword, mode: ModeClass::Both },
|
||||
Candidate { text: "index".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
|
||||
Candidate { text: "relationship".into(), kind: CandidateKind::Keyword, mode: ModeClass::Simple },
|
||||
Candidate {
|
||||
text: "table".into(),
|
||||
kind: CandidateKind::Keyword,
|
||||
mode: ModeClass::Both,
|
||||
},
|
||||
Candidate {
|
||||
text: "index".into(),
|
||||
kind: CandidateKind::Keyword,
|
||||
mode: ModeClass::Advanced,
|
||||
},
|
||||
Candidate {
|
||||
text: "relationship".into(),
|
||||
kind: CandidateKind::Keyword,
|
||||
mode: ModeClass::Simple,
|
||||
},
|
||||
];
|
||||
let line = render_candidate_line(&items, None, 100, &theme);
|
||||
assert_eq!(line.spans[0].content.as_ref(), "table");
|
||||
assert_eq!(line.spans[0].style.fg, Some(theme.tok_keyword), "Both keeps the kind colour");
|
||||
assert_eq!(
|
||||
line.spans[0].style.fg,
|
||||
Some(theme.tok_keyword),
|
||||
"Both keeps the kind colour"
|
||||
);
|
||||
assert_eq!(line.spans[2].content.as_ref(), "index");
|
||||
assert_eq!(line.spans[2].style.fg, Some(theme.mode_advanced), "Advanced → advanced colour");
|
||||
assert_eq!(
|
||||
line.spans[2].style.fg,
|
||||
Some(theme.mode_advanced),
|
||||
"Advanced → advanced colour"
|
||||
);
|
||||
assert_eq!(line.spans[4].content.as_ref(), "relationship");
|
||||
assert_eq!(line.spans[4].style.fg, Some(theme.mode_simple), "Simple → simple colour");
|
||||
assert_eq!(
|
||||
line.spans[4].style.fg,
|
||||
Some(theme.mode_simple),
|
||||
"Simple → simple colour"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2173,8 +2201,16 @@ mod tests {
|
||||
use crate::completion::{Candidate, CandidateKind, ModeClass};
|
||||
let theme = Theme::dark();
|
||||
let items = vec![
|
||||
Candidate { text: "values".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
|
||||
Candidate { text: "select".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
|
||||
Candidate {
|
||||
text: "values".into(),
|
||||
kind: CandidateKind::Keyword,
|
||||
mode: ModeClass::Advanced,
|
||||
},
|
||||
Candidate {
|
||||
text: "select".into(),
|
||||
kind: CandidateKind::Keyword,
|
||||
mode: ModeClass::Advanced,
|
||||
},
|
||||
];
|
||||
let line = render_candidate_line(&items, None, 100, &theme);
|
||||
assert_eq!(
|
||||
@@ -2248,7 +2284,10 @@ mod tests {
|
||||
"the error body is neutral fg, not flooded red",
|
||||
);
|
||||
assert!(
|
||||
rendered.spans[1].style.add_modifier.contains(Modifier::BOLD),
|
||||
rendered.spans[1]
|
||||
.style
|
||||
.add_modifier
|
||||
.contains(Modifier::BOLD),
|
||||
"the error body is bold for weight without the red-wall readability cost",
|
||||
);
|
||||
}
|
||||
@@ -2509,10 +2548,14 @@ mod tests {
|
||||
"the tail around the cursor must be visible:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
!out.lines().any(|l| l.contains("select * from Customers where")),
|
||||
!out.lines()
|
||||
.any(|l| l.contains("select * from Customers where")),
|
||||
"the head must be scrolled off:\n{out}"
|
||||
);
|
||||
assert!(out.contains('<'), "a left scroll marker signals the hidden head:\n{out}");
|
||||
assert!(
|
||||
out.contains('<'),
|
||||
"a left scroll marker signals the hidden head:\n{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2525,9 +2568,18 @@ mod tests {
|
||||
let theme = Theme::dark();
|
||||
// Narrow (sidebar hidden, DB1) so the line overflows the field.
|
||||
let out = render_to_string(&mut app, &theme, 60, 24);
|
||||
assert!(out.contains("select * from"), "head visible at Home:\n{out}");
|
||||
assert!(out.contains('>'), "a right scroll marker signals the hidden tail:\n{out}");
|
||||
assert!(!out.contains("Wonderland"), "the tail must be scrolled off:\n{out}");
|
||||
assert!(
|
||||
out.contains("select * from"),
|
||||
"head visible at Home:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains('>'),
|
||||
"a right scroll marker signals the hidden tail:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
!out.contains("Wonderland"),
|
||||
"the tail must be scrolled off:\n{out}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- ADR-0046 DA4: two-row input on tall terminals -----------
|
||||
@@ -2569,8 +2621,14 @@ mod tests {
|
||||
let theme = Theme::dark();
|
||||
// Very narrow + tall: two rows, but the line exceeds both.
|
||||
let out = render_to_string(&mut app, &theme, 38, 44);
|
||||
assert!(out.contains("Wonderland"), "the tail/cursor stays visible:\n{out}");
|
||||
assert!(out.contains('<'), "a left marker signals the hidden head:\n{out}");
|
||||
assert!(
|
||||
out.contains("Wonderland"),
|
||||
"the tail/cursor stays visible:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains('<'),
|
||||
"a left marker signals the hidden head:\n{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2644,7 +2702,10 @@ mod tests {
|
||||
|
||||
/// The `key` column of the strip's bindings, in order.
|
||||
fn strip_keys(app: &App) -> Vec<&'static str> {
|
||||
status_bar_bindings(app).into_iter().map(|(k, _)| k).collect()
|
||||
status_bar_bindings(app)
|
||||
.into_iter()
|
||||
.map(|(k, _)| k)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The full rendered strip text (keys + labels + separators).
|
||||
@@ -2659,7 +2720,12 @@ mod tests {
|
||||
fn hint_text(lines: &[Line<'_>]) -> String {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
@@ -2686,10 +2752,7 @@ mod tests {
|
||||
fn strip_sidebar_focus_state_is_pane_scroll_input() {
|
||||
let mut app = App::new();
|
||||
app.nav_focus = NavFocus::SidebarTables;
|
||||
assert_eq!(
|
||||
strip_keys(&app),
|
||||
vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],
|
||||
);
|
||||
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],);
|
||||
// ...and the relationships sidebar is the same state.
|
||||
app.nav_focus = NavFocus::SidebarRelationships;
|
||||
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"]);
|
||||
@@ -2802,11 +2865,7 @@ mod tests {
|
||||
assert_eq!(two, vec!["alpha beta", "gamma delta"]);
|
||||
// > max rows: clamp to max, last row ends with an ellipsis,
|
||||
// and every row stays within the width.
|
||||
let many = clamp_wrapped(
|
||||
"alpha beta gamma delta epsilon zeta eta theta iota",
|
||||
11,
|
||||
3,
|
||||
);
|
||||
let many = clamp_wrapped("alpha beta gamma delta epsilon zeta eta theta iota", 11, 3);
|
||||
assert_eq!(many.len(), 3);
|
||||
assert!(many[2].ends_with('…'), "last row ellipsized: {many:?}");
|
||||
for row in &many {
|
||||
@@ -2931,9 +2990,18 @@ mod tests {
|
||||
app.output.push_back(err);
|
||||
|
||||
let out = render_to_string(&mut app, &Theme::dark(), 100, 20);
|
||||
assert!(out.contains("running: drop table Orders"), "pending keeps running::\n{out}");
|
||||
assert!(out.contains("create table T with pk ✓"), "ok shows ✓:\n{out}");
|
||||
assert!(out.contains("insert into T values (1) ✗"), "err shows ✗:\n{out}");
|
||||
assert!(
|
||||
out.contains("running: drop table Orders"),
|
||||
"pending keeps running::\n{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("create table T with pk ✓"),
|
||||
"ok shows ✓:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("insert into T values (1) ✗"),
|
||||
"err shows ✗:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
!out.contains("running: create table"),
|
||||
"a completed echo drops the running: prefix:\n{out}"
|
||||
@@ -2970,7 +3038,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_snapshot_timestamp_falls_back_on_garbage() {
|
||||
assert_eq!(format_snapshot_timestamp("not a timestamp"), "not a timestamp");
|
||||
assert_eq!(
|
||||
format_snapshot_timestamp("not a timestamp"),
|
||||
"not a timestamp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2999,9 +3070,9 @@ mod tests {
|
||||
let theme = Theme::dark();
|
||||
let out = render_to_string(&mut app, &theme, 120, 30);
|
||||
assert!(
|
||||
out.lines().any(|l| l.contains(
|
||||
"This will undo: insert into Customers values (1, 'Oliver Sturm')"
|
||||
)),
|
||||
out.lines()
|
||||
.any(|l| l
|
||||
.contains("This will undo: insert into Customers values (1, 'Oliver Sturm')")),
|
||||
"command must sit on one row on a wide terminal:\n{out}"
|
||||
);
|
||||
}
|
||||
@@ -3017,7 +3088,10 @@ mod tests {
|
||||
}));
|
||||
let theme = Theme::dark();
|
||||
let out = render_to_string(&mut app, &theme, 120, 30);
|
||||
assert!(out.contains("Snapshot taken"), "capitalized Snapshot:\n{out}");
|
||||
assert!(
|
||||
out.contains("Snapshot taken"),
|
||||
"capitalized Snapshot:\n{out}"
|
||||
);
|
||||
assert!(out.contains("[Y] Yes"), "capitalized Yes:\n{out}");
|
||||
assert!(out.contains("[N] No"), "capitalized No:\n{out}");
|
||||
assert!(
|
||||
@@ -3113,8 +3187,14 @@ mod tests {
|
||||
app.schema_cache.table_indexes.insert(
|
||||
"Customers".to_string(),
|
||||
vec![
|
||||
IndexEntry { name: "idx_email".to_string(), unique: false },
|
||||
IndexEntry { name: "uidx_login".to_string(), unique: true },
|
||||
IndexEntry {
|
||||
name: "idx_email".to_string(),
|
||||
unique: false,
|
||||
},
|
||||
IndexEntry {
|
||||
name: "uidx_login".to_string(),
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
let theme = Theme::dark();
|
||||
@@ -3123,7 +3203,10 @@ mod tests {
|
||||
assert!(out.contains("Customers"), "table listed:\n{out}");
|
||||
assert!(out.contains("Orders"), "table listed:\n{out}");
|
||||
assert!(out.contains("idx_email"), "index nested in panel:\n{out}");
|
||||
assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}");
|
||||
assert!(
|
||||
out.contains("uidx_login [unique]"),
|
||||
"unique index marked:\n{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3143,10 +3226,19 @@ mod tests {
|
||||
app.tables = vec!["Customers".to_string()];
|
||||
let theme = Theme::dark();
|
||||
let narrow = render_to_string(&mut app, &theme, 80, 24);
|
||||
assert!(!narrow.contains("Tables"), "sidebar hidden at 80 wide:\n{narrow}");
|
||||
assert!(
|
||||
!narrow.contains("Tables"),
|
||||
"sidebar hidden at 80 wide:\n{narrow}"
|
||||
);
|
||||
let wide = render_to_string(&mut app, &theme, 110, 24);
|
||||
assert!(wide.contains("Tables"), "sidebar shown at 110 wide:\n{wide}");
|
||||
assert!(wide.contains("Customers"), "tables listed when shown:\n{wide}");
|
||||
assert!(
|
||||
wide.contains("Tables"),
|
||||
"sidebar shown at 110 wide:\n{wide}"
|
||||
);
|
||||
assert!(
|
||||
wide.contains("Customers"),
|
||||
"tables listed when shown:\n{wide}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3181,7 +3273,10 @@ mod tests {
|
||||
let theme = Theme::dark();
|
||||
let out = render_to_string(&mut app, &theme, 110, 24);
|
||||
assert!(out.contains("Relationships"), "panel title present:\n{out}");
|
||||
assert!(out.contains("Customers_Orders"), "relationship name:\n{out}");
|
||||
assert!(
|
||||
out.contains("Customers_Orders"),
|
||||
"relationship name:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
out.lines().any(|l| l.contains("Customers.id ->")),
|
||||
"parent endpoint, broken at the arrow:\n{out}"
|
||||
@@ -3228,8 +3323,14 @@ mod tests {
|
||||
|
||||
app.nav_focus = NavFocus::SidebarTables;
|
||||
let focused = render_to_string(&mut app, &theme, 80, 24);
|
||||
assert!(focused.contains("Tables"), "sidebar revealed in nav mode:\n{focused}");
|
||||
assert!(focused.contains("Customers"), "tables in the overlay:\n{focused}");
|
||||
assert!(
|
||||
focused.contains("Tables"),
|
||||
"sidebar revealed in nav mode:\n{focused}"
|
||||
);
|
||||
assert!(
|
||||
focused.contains("Customers"),
|
||||
"tables in the overlay:\n{focused}"
|
||||
);
|
||||
assert!(
|
||||
focused.contains("Relationships"),
|
||||
"relationships panel in the overlay:\n{focused}"
|
||||
@@ -3365,7 +3466,9 @@ mod tests {
|
||||
}
|
||||
let backend = TestBackend::new(width, height);
|
||||
let mut terminal = Terminal::new(backend).expect("create terminal");
|
||||
terminal.draw(|f| render(app, theme, f)).expect("draw frame");
|
||||
terminal
|
||||
.draw(|f| render(app, theme, f))
|
||||
.expect("draw frame");
|
||||
terminal.backend().buffer().clone()
|
||||
}
|
||||
|
||||
|
||||
+15
-3
@@ -737,7 +737,11 @@ mod tests {
|
||||
let payload_dirs = fs::read_dir(&store.root)
|
||||
.unwrap()
|
||||
.filter_map(std::result::Result::ok)
|
||||
.filter(|e| e.file_name().to_str().is_some_and(|n| n.parse::<u64>().is_ok()))
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|n| n.parse::<u64>().is_ok())
|
||||
})
|
||||
.count();
|
||||
assert_eq!(payload_dirs, 2, "ring capped at 2 payloads");
|
||||
}
|
||||
@@ -766,7 +770,11 @@ mod tests {
|
||||
let payload_dirs = fs::read_dir(&store.root)
|
||||
.unwrap()
|
||||
.filter_map(std::result::Result::ok)
|
||||
.filter(|e| e.file_name().to_str().is_some_and(|n| n.parse::<u64>().is_ok()))
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|n| n.parse::<u64>().is_ok())
|
||||
})
|
||||
.count();
|
||||
assert_eq!(payload_dirs, 1, "only the surviving undo payload remains");
|
||||
}
|
||||
@@ -820,6 +828,10 @@ mod tests {
|
||||
fs::create_dir_all(store.payload_dir(41)).unwrap();
|
||||
stage_finalize(&store, &fx.conn, "cmd");
|
||||
let meta = store.peek_undo().unwrap().unwrap();
|
||||
assert!(meta.id >= 42, "id allocated above the orphan, got {}", meta.id);
|
||||
assert!(
|
||||
meta.id >= 42,
|
||||
"id allocated above the orphan, got {}",
|
||||
meta.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
@@ -78,7 +77,10 @@ fn rename_column_with_case_variant_table_keeps_metadata_in_step() {
|
||||
let r = rt();
|
||||
r.block_on(db.create_table(
|
||||
"Items".to_string(),
|
||||
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("qty", Type::Int)],
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Int),
|
||||
ColumnSpec::new("qty", Type::Int),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
Some("create".to_string()),
|
||||
))
|
||||
@@ -129,7 +131,11 @@ fn insert_with_case_variant_table_persists_and_survives_rebuild() {
|
||||
.block_on(db.query_data("Items".to_string(), None, None))
|
||||
.expect("query")
|
||||
.rows;
|
||||
assert_eq!(rows.len(), 1, "the wrong-case insert survived the rebuild (no data loss)");
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
1,
|
||||
"the wrong-case insert survived the rebuild (no data loss)"
|
||||
);
|
||||
assert_eq!(rows[0][1].as_deref(), Some("kept"));
|
||||
}
|
||||
|
||||
@@ -146,9 +152,19 @@ fn add_column_with_case_variant_table_survives_rebuild() {
|
||||
);
|
||||
|
||||
let db = fresh_rebuild(db, &project, &r);
|
||||
let desc = r.block_on(db.describe_table("Items".to_string())).expect("describe");
|
||||
let qty = desc.columns.iter().find(|c| c.name == "qty").expect("qty added");
|
||||
assert_eq!(qty.user_type, Some(Type::Int), "qty's user-type survived the rebuild");
|
||||
let desc = r
|
||||
.block_on(db.describe_table("Items".to_string()))
|
||||
.expect("describe");
|
||||
let qty = desc
|
||||
.columns
|
||||
.iter()
|
||||
.find(|c| c.name == "qty")
|
||||
.expect("qty added");
|
||||
assert_eq!(
|
||||
qty.user_type,
|
||||
Some(Type::Int),
|
||||
"qty's user-type survived the rebuild"
|
||||
);
|
||||
// The CHECK is intact too (a negative qty is refused under the real table).
|
||||
assert!(
|
||||
r.block_on(db.insert(
|
||||
@@ -175,9 +191,15 @@ fn drop_table_with_case_variant_name_clears_table_and_csv() {
|
||||
insert into Items (id, note) values (1, 'x')\n\
|
||||
drop table items\n",
|
||||
);
|
||||
assert!(!tables(&db, &r).contains(&"Items".to_string()), "the table was dropped");
|
||||
assert!(
|
||||
!tables(&db, &r).contains(&"Items".to_string()),
|
||||
"the table was dropped"
|
||||
);
|
||||
let csv = project.path().join(project::DATA_DIR).join("Items.csv");
|
||||
assert!(!csv.exists(), "the CSV was removed despite the case-variant drop");
|
||||
assert!(
|
||||
!csv.exists(),
|
||||
"the CSV was removed despite the case-variant drop"
|
||||
);
|
||||
|
||||
// A fresh rebuild yields no Items (the metadata/yaml has no orphan).
|
||||
let db = fresh_rebuild(db, &project, &r);
|
||||
@@ -224,12 +246,24 @@ fn add_relationship_with_case_variant_tables_survives_rebuild() {
|
||||
add 1:n relationship from parent.id to child.parent_id\n",
|
||||
);
|
||||
// The parent's inbound relationship is visible under the stored case.
|
||||
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent");
|
||||
assert_eq!(p.inbound_relationships.len(), 1, "relationship recorded under the stored case");
|
||||
let p = r
|
||||
.block_on(db.describe_table("Parent".to_string()))
|
||||
.expect("describe Parent");
|
||||
assert_eq!(
|
||||
p.inbound_relationships.len(),
|
||||
1,
|
||||
"relationship recorded under the stored case"
|
||||
);
|
||||
assert_eq!(p.inbound_relationships[0].other_table, "Child");
|
||||
|
||||
let db = fresh_rebuild(db, &project, &r);
|
||||
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent");
|
||||
assert_eq!(p.inbound_relationships.len(), 1, "relationship survived the rebuild");
|
||||
let p = r
|
||||
.block_on(db.describe_table("Parent".to_string()))
|
||||
.expect("describe Parent");
|
||||
assert_eq!(
|
||||
p.inbound_relationships.len(),
|
||||
1,
|
||||
"relationship survived the rebuild"
|
||||
);
|
||||
assert_eq!(p.inbound_relationships[0].other_table, "Child");
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, true)
|
||||
.expect("open db with persistence");
|
||||
@@ -48,7 +47,9 @@ fn make_t_with_check(db: &Database, r: &tokio::runtime::Runtime) {
|
||||
vec!["a < b".to_string()],
|
||||
vec![],
|
||||
false,
|
||||
Some("create table T (id int primary key, a int, b int, c text, check (a < b))".to_string()),
|
||||
Some(
|
||||
"create table T (id int primary key, a int, b int, c text, check (a < b))".to_string(),
|
||||
),
|
||||
))
|
||||
.expect("create T with table CHECK");
|
||||
}
|
||||
@@ -285,7 +286,10 @@ fn drop_column_covered_by_a_composite_unique_is_refused_with_the_derived_name()
|
||||
.block_on(db.drop_column("T".to_string(), "a".to_string(), false, None))
|
||||
.expect_err("dropping a composite-UNIQUE column is refused");
|
||||
let msg = err.friendly_message();
|
||||
assert!(msg.contains("unique_a_b"), "names the derived constraint; got: {msg}");
|
||||
assert!(
|
||||
msg.contains("unique_a_b"),
|
||||
"names the derived constraint; got: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("drop constraint unique_a_b"),
|
||||
"points at the actionable drop command; got: {msg}"
|
||||
@@ -351,13 +355,23 @@ fn rename_column_with_a_column_level_check_is_refused() {
|
||||
make_t_with_column_checks(&db, &r);
|
||||
// `qty`'s own check `qty >= 0` references qty → refused.
|
||||
assert!(
|
||||
r.block_on(db.rename_column("T".to_string(), "qty".to_string(), "amount".to_string(), None))
|
||||
r.block_on(db.rename_column(
|
||||
"T".to_string(),
|
||||
"qty".to_string(),
|
||||
"amount".to_string(),
|
||||
None
|
||||
))
|
||||
.is_err(),
|
||||
"renaming a column with its own column-level CHECK is refused"
|
||||
);
|
||||
// `price` is referenced by `discount`'s check `discount < price`.
|
||||
assert!(
|
||||
r.block_on(db.rename_column("T".to_string(), "price".to_string(), "cost".to_string(), None))
|
||||
r.block_on(db.rename_column(
|
||||
"T".to_string(),
|
||||
"price".to_string(),
|
||||
"cost".to_string(),
|
||||
None
|
||||
))
|
||||
.is_err(),
|
||||
"renaming a column referenced by another column's CHECK is refused"
|
||||
);
|
||||
|
||||
+49
-26
@@ -9,7 +9,7 @@
|
||||
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::{
|
||||
parse_command, ColumnSpec, Command, ReferentialAction, SqlForeignKey, Type, Value,
|
||||
ColumnSpec, Command, ReferentialAction, SqlForeignKey, Type, Value, parse_command,
|
||||
};
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project;
|
||||
@@ -18,10 +18,8 @@ use rdbms_playground::project;
|
||||
|
||||
#[test]
|
||||
fn parenthesized_compound_endpoint_parses_to_column_lists() {
|
||||
let cmd = parse_command(
|
||||
"add 1:n relationship from Parent.(a, b) to Child.(x, y)",
|
||||
)
|
||||
.expect("parses");
|
||||
let cmd =
|
||||
parse_command("add 1:n relationship from Parent.(a, b) to Child.(x, y)").expect("parses");
|
||||
match cmd {
|
||||
Command::AddRelationship {
|
||||
parent_table,
|
||||
@@ -41,8 +39,7 @@ fn parenthesized_compound_endpoint_parses_to_column_lists() {
|
||||
|
||||
#[test]
|
||||
fn single_column_endpoint_still_parses_unparenthesized() {
|
||||
let cmd = parse_command("add 1:n relationship from Parent.id to Child.pid")
|
||||
.expect("parses");
|
||||
let cmd = parse_command("add 1:n relationship from Parent.id to Child.pid").expect("parses");
|
||||
match cmd {
|
||||
Command::AddRelationship {
|
||||
parent_columns,
|
||||
@@ -148,7 +145,10 @@ fn sql_create_table_compound_fk_executes_and_enforces() {
|
||||
db.insert(
|
||||
"Region".to_string(),
|
||||
Some(vec!["country".to_string(), "code".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("10".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -157,7 +157,10 @@ fn sql_create_table_compound_fk_executes_and_enforces() {
|
||||
.insert(
|
||||
"City".to_string(),
|
||||
Some(vec!["country".to_string(), "region_code".to_string()]),
|
||||
vec![Value::Number("9".to_string()), Value::Number("9".to_string())],
|
||||
vec![
|
||||
Value::Number("9".to_string()),
|
||||
Value::Number("9".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
@@ -176,8 +179,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence(project.db_path(), persistence)
|
||||
.expect("open db with persistence");
|
||||
@@ -241,7 +243,10 @@ fn compound_fk_declares_enforces_and_round_trips() {
|
||||
db.insert(
|
||||
"Region".to_string(),
|
||||
Some(vec!["country".to_string(), "code".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("10".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -253,7 +258,11 @@ fn compound_fk_declares_enforces_and_round_trips() {
|
||||
"region_code".to_string(),
|
||||
"name".to_string(),
|
||||
]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("10".to_string()), Value::Text("Metropolis".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("10".to_string()),
|
||||
Value::Text("Metropolis".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -266,7 +275,11 @@ fn compound_fk_declares_enforces_and_round_trips() {
|
||||
"region_code".to_string(),
|
||||
"name".to_string(),
|
||||
]),
|
||||
vec![Value::Number("9".to_string()), Value::Number("9".to_string()), Value::Text("Nowhere".to_string())],
|
||||
vec![
|
||||
Value::Number("9".to_string()),
|
||||
Value::Number("9".to_string()),
|
||||
Value::Text("Nowhere".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
@@ -360,7 +373,10 @@ fn compound_fk_arity_mismatch_is_refused() {
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(err.is_err(), "mismatched child/parent arity must be refused");
|
||||
assert!(
|
||||
err.is_err(),
|
||||
"mismatched child/parent arity must be refused"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -386,9 +402,7 @@ fn inline_fk_referencing_compound_pk_points_at_table_level_form() {
|
||||
.expect("create Region");
|
||||
|
||||
// Parse the inline form so the `inline` flag is set by the grammar.
|
||||
let cmd = parse_command(
|
||||
"create table City (country int references Region(country, code))",
|
||||
)
|
||||
let cmd = parse_command("create table City (country int references Region(country, code))")
|
||||
.expect("parses");
|
||||
let Command::SqlCreateTable {
|
||||
name,
|
||||
@@ -465,7 +479,10 @@ fn compound_fk_type_mismatch_per_pair_is_refused() {
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(err.is_err(), "a type-incompatible column pair must be refused");
|
||||
assert!(
|
||||
err.is_err(),
|
||||
"a type-incompatible column pair must be refused"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -478,10 +495,7 @@ fn compound_fk_survives_rebuild_from_text() {
|
||||
let path = project.path().to_path_buf();
|
||||
let rt = rt();
|
||||
{
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.expect("open db");
|
||||
rt.block_on(async {
|
||||
seed_compound(&db).await;
|
||||
@@ -512,7 +526,10 @@ fn compound_fk_survives_rebuild_from_text() {
|
||||
db.insert(
|
||||
"Region".to_string(),
|
||||
Some(vec!["country".to_string(), "code".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("10".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -521,11 +538,17 @@ fn compound_fk_survives_rebuild_from_text() {
|
||||
.insert(
|
||||
"City".to_string(),
|
||||
Some(vec!["country".to_string(), "region_code".to_string()]),
|
||||
vec![Value::Number("9".to_string()), Value::Number("9".to_string())],
|
||||
vec![
|
||||
Value::Number("9".to_string()),
|
||||
Value::Number("9".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(bad.is_err(), "compound FK still enforced after rebuild from text");
|
||||
assert!(
|
||||
bad.is_err(),
|
||||
"compound FK still enforced after rebuild from text"
|
||||
);
|
||||
// Endpoints survived the round-trip intact.
|
||||
let city = db.describe_table("City".to_string()).await.unwrap();
|
||||
assert_eq!(
|
||||
|
||||
@@ -32,10 +32,8 @@ use rdbms_playground::event::AppEvent;
|
||||
|
||||
const FORBIDDEN: &[&str] = &[
|
||||
// Product names.
|
||||
"SQLite", "sqlite",
|
||||
// Crate name.
|
||||
"rusqlite",
|
||||
// Engine-specific keywords / idioms.
|
||||
"SQLite", "sqlite", // Crate name.
|
||||
"rusqlite", // Engine-specific keywords / idioms.
|
||||
"STRICT", "PRAGMA",
|
||||
];
|
||||
|
||||
@@ -52,9 +50,7 @@ fn engine_vocab_leak(s: &str) -> Option<(&'static str, usize)> {
|
||||
|
||||
fn assert_clean(label: &str, s: &str) {
|
||||
if let Some((needle, pos)) = engine_vocab_leak(s) {
|
||||
panic!(
|
||||
"ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}"
|
||||
);
|
||||
panic!("ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,8 +114,7 @@ fn parse_errors_use_no_engine_vocabulary() {
|
||||
"this is not a command",
|
||||
];
|
||||
for input in inputs {
|
||||
let err = parse_command(input)
|
||||
.expect_err(&format!("expected parse failure for `{input}`"));
|
||||
let err = parse_command(input).expect_err(&format!("expected parse failure for `{input}`"));
|
||||
let rendered = format!("{err:?}");
|
||||
assert_clean(&format!("parse error for `{input}`"), &rendered);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use rdbms_playground::db::{Database, DbError, SqliteErrorKind};
|
||||
use rdbms_playground::dsl::{
|
||||
action::ReferentialAction, ColumnSpec, Command, RowFilter, Type, Value,
|
||||
};
|
||||
use rdbms_playground::dsl::parser::parse_command;
|
||||
use rdbms_playground::dsl::{
|
||||
ColumnSpec, Command, RowFilter, Type, Value, action::ReferentialAction,
|
||||
};
|
||||
use rdbms_playground::runtime::enrich_dsl_failure;
|
||||
|
||||
fn rt() -> Runtime {
|
||||
@@ -57,7 +57,10 @@ fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() {
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())],
|
||||
vec![
|
||||
Value::Number("5".to_string()),
|
||||
Value::Text("Alice".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -86,7 +89,10 @@ fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() {
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
|
||||
DbError::Sqlite {
|
||||
kind: SqliteErrorKind::UniqueViolation,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
@@ -169,7 +175,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())],
|
||||
vec![
|
||||
Value::Number("5".to_string()),
|
||||
Value::Text("Alice".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -189,7 +198,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
|
||||
else {
|
||||
panic!("expected Command::SqlInsert, got {cmd:?}");
|
||||
};
|
||||
assert!(listed_columns.is_empty(), "natural-order form has no column list");
|
||||
assert!(
|
||||
listed_columns.is_empty(),
|
||||
"natural-order form has no column list"
|
||||
);
|
||||
let err = db
|
||||
.run_sql_insert_with_literals(
|
||||
sql,
|
||||
@@ -204,7 +216,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
|
||||
DbError::Sqlite {
|
||||
kind: SqliteErrorKind::UniqueViolation,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
@@ -235,7 +250,10 @@ fn enrich_unique_update_resolves_value_from_assignments() {
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Text("Alice".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -243,7 +261,10 @@ fn enrich_unique_update_resolves_value_from_assignments() {
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())],
|
||||
vec![
|
||||
Value::Number("2".to_string()),
|
||||
Value::Text("Bob".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -294,7 +315,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Text("Alice".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -302,7 +326,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
|
||||
db.insert(
|
||||
"Customers".to_string(),
|
||||
None,
|
||||
vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())],
|
||||
vec![
|
||||
Value::Number("2".to_string()),
|
||||
Value::Text("Bob".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -328,7 +355,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
|
||||
DbError::Sqlite {
|
||||
kind: SqliteErrorKind::UniqueViolation,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
@@ -666,7 +696,10 @@ fn enrich_fk_delete_resolves_child_table() {
|
||||
db.insert(
|
||||
"Orders".to_string(),
|
||||
None,
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("1".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -708,9 +741,8 @@ fn enrich_check_insert_resolves_table_column_value_and_rule() {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let score_spec = match parse_command(
|
||||
"create table __probe with pk score(int) check (score >= 0)",
|
||||
)
|
||||
let score_spec =
|
||||
match parse_command("create table __probe with pk score(int) check (score >= 0)")
|
||||
.expect("probe create parses")
|
||||
{
|
||||
Command::CreateTable { columns, .. } => {
|
||||
@@ -757,7 +789,9 @@ fn enrich_unsupported_returns_default_facts() {
|
||||
let db = db();
|
||||
rt().block_on(async {
|
||||
let err = DbError::Unsupported("nope".to_string());
|
||||
let cmd = Command::DropTable { name: "X".to_string() };
|
||||
let cmd = Command::DropTable {
|
||||
name: "X".to_string(),
|
||||
};
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
assert!(facts.table.is_none());
|
||||
assert!(facts.column.is_none());
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
|
||||
use rdbms_playground::app::App;
|
||||
use rdbms_playground::dsl::{parse_command, AppCommand, Command};
|
||||
use rdbms_playground::dsl::{AppCommand, Command, parse_command};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
|
||||
const fn key(code: KeyCode) -> AppEvent {
|
||||
|
||||
@@ -14,9 +14,7 @@ use std::path::Path;
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value};
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project::{
|
||||
self, DATA_DIR, PROJECT_YAML,
|
||||
};
|
||||
use rdbms_playground::project::{self, DATA_DIR, PROJECT_YAML};
|
||||
|
||||
fn tempdir() -> tempfile::TempDir {
|
||||
tempfile::tempdir().expect("create tempdir")
|
||||
@@ -33,9 +31,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
/// `Database` (with persistence wired) plus the path so the
|
||||
/// test can inspect on-disk state. The project is held alive
|
||||
/// implicitly via the leaked `TempDir` returned alongside.
|
||||
fn open_project(
|
||||
data: &tempfile::TempDir,
|
||||
) -> (project::Project, Database, std::path::PathBuf) {
|
||||
fn open_project(data: &tempfile::TempDir) -> (project::Project, Database, std::path::PathBuf) {
|
||||
let project = project::open_or_create(None, Some(data.path())).expect("open project");
|
||||
let path = project.path().to_path_buf();
|
||||
let persistence = Persistence::new(path.clone());
|
||||
@@ -72,7 +68,10 @@ fn create_table_writes_yaml_and_history() {
|
||||
});
|
||||
|
||||
let yaml = read_yaml(&path);
|
||||
assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}");
|
||||
assert!(
|
||||
yaml.contains("- name: Customers"),
|
||||
"yaml missing table:\n{yaml}"
|
||||
);
|
||||
assert!(yaml.contains("primary_key: [id]"), "yaml: {yaml}");
|
||||
assert!(yaml.contains("type: serial"), "yaml: {yaml}");
|
||||
assert!(yaml.contains("type: text"), "yaml: {yaml}");
|
||||
@@ -151,9 +150,15 @@ fn drop_table_removes_its_csv() {
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
assert!(read_csv(&path, "Customers").is_none(), "CSV should be deleted");
|
||||
assert!(
|
||||
read_csv(&path, "Customers").is_none(),
|
||||
"CSV should be deleted"
|
||||
);
|
||||
let yaml = read_yaml(&path);
|
||||
assert!(!yaml.contains("- name: Customers"), "table should be gone from yaml:\n{yaml}");
|
||||
assert!(
|
||||
!yaml.contains("- name: Customers"),
|
||||
"table should be gone from yaml:\n{yaml}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -263,7 +268,10 @@ fn create_table_does_not_write_csv_for_empty_table() {
|
||||
|
||||
// Schema landed in YAML.
|
||||
let yaml = read_yaml(&path);
|
||||
assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}");
|
||||
assert!(
|
||||
yaml.contains("- name: Customers"),
|
||||
"yaml missing table:\n{yaml}"
|
||||
);
|
||||
// ...but no CSV until there's data.
|
||||
assert!(
|
||||
read_csv(&path, "Customers").is_none(),
|
||||
@@ -394,7 +402,10 @@ fn project_yaml_carries_relationship_after_add() {
|
||||
});
|
||||
|
||||
let yaml = read_yaml(&path);
|
||||
assert!(yaml.contains("- name: Customers_id_to_Orders_CustId"), "yaml: {yaml}");
|
||||
assert!(
|
||||
yaml.contains("- name: Customers_id_to_Orders_CustId"),
|
||||
"yaml: {yaml}"
|
||||
);
|
||||
assert!(yaml.contains("on_delete: cascade"), "yaml: {yaml}");
|
||||
assert!(yaml.contains("on_update: no_action"), "yaml: {yaml}");
|
||||
}
|
||||
|
||||
@@ -35,10 +35,7 @@ fn rebuild_restores_schema_only_project() {
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
@@ -89,10 +86,7 @@ fn rebuild_restores_rows_from_csv() {
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
@@ -157,10 +151,7 @@ fn rebuild_restores_relationships_and_cascade_behaviour() {
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
@@ -244,7 +235,11 @@ fn rebuild_restores_relationships_and_cascade_behaviour() {
|
||||
})
|
||||
.expect("delete");
|
||||
assert_eq!(result.rows_affected, 1);
|
||||
assert_eq!(result.cascade.len(), 1, "expected one cascade entry: {result:?}");
|
||||
assert_eq!(
|
||||
result.cascade.len(),
|
||||
1,
|
||||
"expected one cascade entry: {result:?}"
|
||||
);
|
||||
assert_eq!(result.cascade[0].child_table, "Orders");
|
||||
}
|
||||
|
||||
@@ -256,10 +251,7 @@ fn rebuild_reports_fatal_error_on_bad_csv_row() {
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
@@ -303,13 +295,17 @@ fn rebuild_reports_fatal_error_on_bad_csv_row() {
|
||||
.unwrap();
|
||||
let err = rt()
|
||||
.block_on(async {
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None).await
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None)
|
||||
.await
|
||||
})
|
||||
.expect_err("must fail with row-level error");
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("row 2"), "msg should name the row: {msg}");
|
||||
assert!(msg.contains("Numbers"), "msg should name the table: {msg}");
|
||||
assert!(msg.contains("integer"), "msg should explain the type mismatch: {msg}");
|
||||
assert!(
|
||||
msg.contains("integer"),
|
||||
"msg should explain the type mismatch: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -318,10 +314,7 @@ fn rebuild_preserves_created_at_from_yaml() {
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
@@ -371,9 +364,7 @@ fn rebuild_preserves_created_at_from_yaml() {
|
||||
// Trigger any successful command so project.yaml is
|
||||
// rewritten from the now-rebuilt db state.
|
||||
rt().block_on(async {
|
||||
db.describe_table("T".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
db.describe_table("T".to_string()).await.unwrap();
|
||||
// describe is read-only; force a rewrite by adding a column.
|
||||
db.add_column(
|
||||
"T".to_string(),
|
||||
@@ -400,10 +391,7 @@ fn rebuild_restores_indexes() {
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
@@ -434,7 +422,10 @@ fn rebuild_restores_indexes() {
|
||||
// The index must be recorded in project.yaml — the `.db` is
|
||||
// a derived artifact and gets discarded next.
|
||||
let yaml = fs::read_to_string(project_path.join(project::PROJECT_YAML)).unwrap();
|
||||
assert!(yaml.contains("idx_email"), "yaml should record the index:\n{yaml}");
|
||||
assert!(
|
||||
yaml.contains("idx_email"),
|
||||
"yaml should record the index:\n{yaml}"
|
||||
);
|
||||
|
||||
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
|
||||
|
||||
|
||||
@@ -113,7 +113,10 @@ fn modal_swallows_unrelated_keys() {
|
||||
// field while the modal is up.
|
||||
app.update(key(KeyCode::Char('x')));
|
||||
assert!(app.input.is_empty(), "modal should swallow key input");
|
||||
assert!(app.modal.is_some(), "modal still active after unrelated key");
|
||||
assert!(
|
||||
app.modal.is_some(),
|
||||
"modal still active after unrelated key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -122,10 +125,7 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
@@ -156,7 +156,9 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
|
||||
// Hand-edit the CSV to introduce a different row content.
|
||||
// Rebuild should pick up the edited content.
|
||||
let csv_path = project_path.join("data").join("Customers.csv");
|
||||
let edited = fs::read_to_string(&csv_path).unwrap().replace("Alice", "Edna");
|
||||
let edited = fs::read_to_string(&csv_path)
|
||||
.unwrap()
|
||||
.replace("Alice", "Edna");
|
||||
fs::write(&csv_path, edited).unwrap();
|
||||
|
||||
// Reopen with persistence (the .db still exists but has
|
||||
|
||||
@@ -16,9 +16,9 @@ use rdbms_playground::app::{
|
||||
App, LoadPickerEntry, LoadPickerModal, LoadPickerSubMode, Modal, PathEntryModal,
|
||||
PathEntryPurpose,
|
||||
};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::{ColumnSpec, Type};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project::{
|
||||
self, Project, ProjectKind, copy_project, safely_delete_temp_project,
|
||||
@@ -462,11 +462,8 @@ fn temp_with_a_table_is_no_longer_unmodified() {
|
||||
let data = tempdir();
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(path.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let db =
|
||||
Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())).unwrap();
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
|
||||
@@ -14,8 +14,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use rdbms_playground::action::Action;
|
||||
use rdbms_playground::app::App;
|
||||
use rdbms_playground::archive::{
|
||||
default_export_filename, export_project, extract_into, inspect_zip,
|
||||
next_export_sequence, resolve_import_target,
|
||||
default_export_filename, export_project, extract_into, inspect_zip, next_export_sequence,
|
||||
resolve_import_target,
|
||||
};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML};
|
||||
@@ -295,10 +295,8 @@ fn end_to_end_export_then_import_real_project() {
|
||||
// Build a populated source project.
|
||||
let src_path = {
|
||||
let p = project::Project::create_named(&data.path().join("Source")).unwrap();
|
||||
let db = Database::open_with_persistence(
|
||||
p.db_path(),
|
||||
Persistence::new(p.path().to_path_buf()),
|
||||
)
|
||||
let db =
|
||||
Database::open_with_persistence(p.db_path(), Persistence::new(p.path().to_path_buf()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
@@ -362,7 +360,11 @@ fn end_to_end_export_then_import_real_project() {
|
||||
|
||||
// Round-trip: the inserted row is back.
|
||||
let data_view = rt()
|
||||
.block_on(async { imported_db.query_data("Customers".to_string(), None, None).await })
|
||||
.block_on(async {
|
||||
imported_db
|
||||
.query_data("Customers".to_string(), None, None)
|
||||
.await
|
||||
})
|
||||
.expect("query data");
|
||||
assert_eq!(data_view.rows.len(), 1);
|
||||
// Serial id auto-filled to 1; Name was the inserted value.
|
||||
|
||||
@@ -166,7 +166,8 @@ fn hydration_reads_both_ok_and_err_records() {
|
||||
let project = Project::create_temp(tmp.path()).unwrap();
|
||||
let p = Persistence::new(project.path().to_path_buf());
|
||||
p.append_history("create table A with pk", false).unwrap();
|
||||
p.append_history_failure("insert into A (1, 2, 3)", false).unwrap();
|
||||
p.append_history_failure("insert into A (1, 2, 3)", false)
|
||||
.unwrap();
|
||||
p.append_history("show data A", false).unwrap();
|
||||
let entries = p.read_recent_history(10).unwrap();
|
||||
assert_eq!(
|
||||
|
||||
+161
-41
@@ -8,7 +8,7 @@
|
||||
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::command::RowFilter;
|
||||
use rdbms_playground::dsl::{parse_command, ColumnSpec, Command, Type, Value};
|
||||
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project::{self, PLAYGROUND_DB};
|
||||
|
||||
@@ -22,7 +22,10 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
fn open() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("project");
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
)
|
||||
.expect("db");
|
||||
(project, db, dir)
|
||||
}
|
||||
@@ -45,7 +48,10 @@ fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
|
||||
async fn serial_pk_table(db: &Database, name: &str) {
|
||||
db.create_table(
|
||||
name.to_string(),
|
||||
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)],
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Serial),
|
||||
ColumnSpec::new("label", Type::Text),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
@@ -84,7 +90,9 @@ fn parses_with_as_name() {
|
||||
match parse_command("create m:n relationship from Students to Courses as Enrollments")
|
||||
.expect("parses")
|
||||
{
|
||||
Command::CreateM2nRelationship { name, .. } => assert_eq!(name.as_deref(), Some("Enrollments")),
|
||||
Command::CreateM2nRelationship { name, .. } => {
|
||||
assert_eq!(name.as_deref(), Some("Enrollments"))
|
||||
}
|
||||
other => panic!("expected CreateM2nRelationship, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -104,12 +112,21 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
|
||||
|
||||
// Auto-named `Students_Courses` exists.
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}");
|
||||
assert!(
|
||||
tables.contains(&"Students_Courses".to_string()),
|
||||
"tables: {tables:?}"
|
||||
);
|
||||
|
||||
// Two FK columns, both part of the compound PK.
|
||||
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap();
|
||||
let cols: Vec<(&str, bool)> =
|
||||
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect();
|
||||
let desc = db
|
||||
.describe_table("Students_Courses".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let cols: Vec<(&str, bool)> = desc
|
||||
.columns
|
||||
.iter()
|
||||
.map(|c| (c.name.as_str(), c.primary_key))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
cols,
|
||||
vec![("Students_id", true), ("Courses_id", true)],
|
||||
@@ -124,7 +141,10 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
|
||||
db.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("1".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -134,21 +154,33 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
|
||||
.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("1".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(dup.is_err(), "duplicate (Students_id, Courses_id) must be refused");
|
||||
assert!(
|
||||
dup.is_err(),
|
||||
"duplicate (Students_id, Courses_id) must be refused"
|
||||
);
|
||||
// A link to a non-existent parent is refused by the FK.
|
||||
let orphan = db
|
||||
.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("99".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("99".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(orphan.is_err(), "link to a non-existent Course must be refused");
|
||||
assert!(
|
||||
orphan.is_err(),
|
||||
"link to a non-existent Course must be refused"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -167,7 +199,10 @@ fn as_name_overrides_the_junction_table_name() {
|
||||
.await
|
||||
.expect("create m:n as Enrollments");
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
|
||||
assert!(
|
||||
tables.contains(&"Enrollments".to_string()),
|
||||
"tables: {tables:?}"
|
||||
);
|
||||
assert!(!tables.contains(&"Students_Courses".to_string()));
|
||||
});
|
||||
}
|
||||
@@ -179,7 +214,10 @@ fn compound_parent_pk_contributes_one_fk_column_each() {
|
||||
// Sections has a 2-column PK (course_id, term).
|
||||
db.create_table(
|
||||
"Sections".to_string(),
|
||||
vec![ColumnSpec::new("course_id", Type::Int), ColumnSpec::new("term", Type::Int)],
|
||||
vec![
|
||||
ColumnSpec::new("course_id", Type::Int),
|
||||
ColumnSpec::new("term", Type::Int),
|
||||
],
|
||||
vec!["course_id".to_string(), "term".to_string()],
|
||||
None,
|
||||
)
|
||||
@@ -191,11 +229,20 @@ fn compound_parent_pk_contributes_one_fk_column_each() {
|
||||
.await
|
||||
.expect("create m:n");
|
||||
|
||||
let desc = db.describe_table("Students_Sections".to_string()).await.unwrap();
|
||||
let desc = db
|
||||
.describe_table("Students_Sections".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]);
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["Students_id", "Sections_course_id", "Sections_term"]
|
||||
);
|
||||
// All three form the compound PK.
|
||||
assert!(desc.columns.iter().all(|c| c.primary_key), "all columns are PK: {names:?}");
|
||||
assert!(
|
||||
desc.columns.iter().all(|c| c.primary_key),
|
||||
"all columns are PK: {names:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -213,16 +260,28 @@ fn deleting_a_parent_cascades_to_the_junction() {
|
||||
db.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("1".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Deleting the student cascades to the junction (ON DELETE CASCADE).
|
||||
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap();
|
||||
let rows = db.query_data("Students_Courses".to_string(), None, None).await.unwrap();
|
||||
assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows);
|
||||
db.delete("Students".to_string(), RowFilter::AllRows, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let rows = db
|
||||
.query_data("Students_Courses".to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
rows.rows.is_empty(),
|
||||
"junction rows should cascade-delete, got {:?}",
|
||||
rows.rows
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -242,15 +301,26 @@ fn create_m2n_is_one_undo_step() {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(db.list_tables().await.unwrap().contains(&"Students_Courses".to_string()));
|
||||
assert!(
|
||||
db.list_tables()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains(&"Students_Courses".to_string())
|
||||
);
|
||||
|
||||
// One undo removes the junction table AND both relationships.
|
||||
db.undo().await.unwrap();
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}");
|
||||
assert!(
|
||||
!tables.contains(&"Students_Courses".to_string()),
|
||||
"undo should remove the junction: {tables:?}"
|
||||
);
|
||||
// The parents' relationships are gone too (the junction held them).
|
||||
let students = db.describe_table("Students".to_string()).await.unwrap();
|
||||
assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo");
|
||||
assert!(
|
||||
students.inbound_relationships.is_empty(),
|
||||
"no leftover relationship after undo"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -265,7 +335,10 @@ fn self_referential_m2n_is_refused() {
|
||||
.create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None)
|
||||
.await
|
||||
.expect_err("self m:n must be refused");
|
||||
assert!(format!("{err}").contains("two different tables"), "got: {err}");
|
||||
assert!(
|
||||
format!("{err}").contains("two different tables"),
|
||||
"got: {err}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -275,11 +348,19 @@ fn missing_parent_table_is_refused() {
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
let err = db
|
||||
.create_m2n_relationship("Students".to_string(), "Nonexistent".to_string(), None, None)
|
||||
.create_m2n_relationship(
|
||||
"Students".to_string(),
|
||||
"Nonexistent".to_string(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("a missing parent table must be refused");
|
||||
// The standard "no such table" guard (require_canonical_table).
|
||||
assert!(format!("{err}").to_lowercase().contains("no such table"), "got: {err}");
|
||||
assert!(
|
||||
format!("{err}").to_lowercase().contains("no such table"),
|
||||
"got: {err}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -297,7 +378,10 @@ fn junction_name_collision_is_refused() {
|
||||
.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.expect_err("a junction-name collision must be refused");
|
||||
assert!(format!("{err}").to_lowercase().contains("exist"), "got: {err}");
|
||||
assert!(
|
||||
format!("{err}").to_lowercase().contains("exist"),
|
||||
"got: {err}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -314,15 +398,26 @@ fn the_junction_can_be_renamed() {
|
||||
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
db.rename_table("Students_Courses".to_string(), "Enrollments".to_string(), None)
|
||||
db.rename_table(
|
||||
"Students_Courses".to_string(),
|
||||
"Enrollments".to_string(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("rename the junction");
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
|
||||
assert!(
|
||||
tables.contains(&"Enrollments".to_string()),
|
||||
"tables: {tables:?}"
|
||||
);
|
||||
assert!(!tables.contains(&"Students_Courses".to_string()));
|
||||
// Both relationships survive the rename (rebuild-preserving).
|
||||
let desc = db.describe_table("Enrollments".to_string()).await.unwrap();
|
||||
assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename");
|
||||
assert_eq!(
|
||||
desc.outbound_relationships.len(),
|
||||
2,
|
||||
"FKs preserved across rename"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -355,16 +450,33 @@ fn junction_survives_save_and_rebuild() {
|
||||
// Discard the derived .db so the next open rebuilds from text.
|
||||
std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
|
||||
let project = project::Project::open(&project_path).unwrap();
|
||||
let db =
|
||||
Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
)
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild");
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None)
|
||||
.await
|
||||
.expect("rebuild");
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}");
|
||||
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap();
|
||||
assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed");
|
||||
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed");
|
||||
assert!(
|
||||
tables.contains(&"Students_Courses".to_string()),
|
||||
"junction survived: {tables:?}"
|
||||
);
|
||||
let desc = db
|
||||
.describe_table("Students_Courses".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
desc.outbound_relationships.len(),
|
||||
2,
|
||||
"both FKs reconstructed"
|
||||
);
|
||||
assert!(
|
||||
desc.columns.iter().all(|c| c.primary_key),
|
||||
"compound PK reconstructed"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -387,7 +499,12 @@ fn as_an_internal_name_is_refused() {
|
||||
.await
|
||||
.expect_err("an internal junction name must be refused");
|
||||
assert!(format!("{err}").contains("no such table"), "got: {err}");
|
||||
assert!(!db.list_tables().await.unwrap().contains(&"__rdbms_evil".to_string()));
|
||||
assert!(
|
||||
!db.list_tables()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains(&"__rdbms_evil".to_string())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -442,7 +559,10 @@ fn read_all_relationships_returns_the_junction_relationships() {
|
||||
);
|
||||
// Both have the junction (Students_Courses) as their child.
|
||||
for r in &rels {
|
||||
assert_eq!(r.child_table, "Students_Courses", "child is the junction: {r:?}");
|
||||
assert_eq!(
|
||||
r.child_table, "Students_Courses",
|
||||
"child is the junction: {r:?}"
|
||||
);
|
||||
}
|
||||
// One points back to each parent.
|
||||
let parents: std::collections::BTreeSet<&str> =
|
||||
|
||||
+1
-1
@@ -24,6 +24,7 @@ mod parse_error_pedagogy;
|
||||
mod project_lifecycle;
|
||||
mod replay_command;
|
||||
mod seed;
|
||||
mod show_list;
|
||||
mod sql_alter_table;
|
||||
mod sql_create_index;
|
||||
mod sql_create_table;
|
||||
@@ -34,6 +35,5 @@ mod sql_drop_table;
|
||||
mod sql_insert;
|
||||
mod sql_select;
|
||||
mod sql_update;
|
||||
mod show_list;
|
||||
mod undo_snapshots;
|
||||
mod walking_skeleton;
|
||||
|
||||
@@ -54,10 +54,7 @@ fn error_lines_for(input: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn dump(input: &str, lines: &[String]) -> String {
|
||||
format!(
|
||||
"INPUT: {input:?}\nERROR LINES:\n{}",
|
||||
lines.join("\n"),
|
||||
)
|
||||
format!("INPUT: {input:?}\nERROR LINES:\n{}", lines.join("\n"),)
|
||||
}
|
||||
|
||||
/// The simple-mode near-miss matrix (ADR-0042 §1). Each row is a
|
||||
@@ -71,57 +68,228 @@ fn near_miss_matrix_simple_mode() {
|
||||
// app-lifecycle arg errors. The arg-less commands all reject
|
||||
// trailing junk with "expected end of input" + their usage
|
||||
// (audited 2026-06-05); locked here as regression insurance.
|
||||
("quit now", &["after `quit`, expected end of input", " quit"]),
|
||||
(
|
||||
"quit now",
|
||||
&["after `quit`, expected end of input", " quit"],
|
||||
),
|
||||
// `help` now takes an optional single-word topic (H3), so
|
||||
// `help foo` parses (topic lookup); only a *multi-word*
|
||||
// topic is the near-miss that rejects trailing junk.
|
||||
("help foo bar", &["after `help foo`, expected end of input", "help [<command>]"]),
|
||||
("rebuild now", &["after `rebuild`, expected end of input", " rebuild"]),
|
||||
(
|
||||
"help foo bar",
|
||||
&[
|
||||
"after `help foo`, expected end of input",
|
||||
"help [<command>]",
|
||||
],
|
||||
),
|
||||
(
|
||||
"rebuild now",
|
||||
&["after `rebuild`, expected end of input", " rebuild"],
|
||||
),
|
||||
("new foo", &["after `new`, expected end of input", " new"]),
|
||||
("load foo", &["after `load`, expected end of input", " load"]),
|
||||
("undo foo", &["after `undo`, expected end of input", " undo"]),
|
||||
("redo foo", &["after `redo`, expected end of input", " redo"]),
|
||||
("export foo bar", &["after `export foo`, expected end of input", "export [<path>]"]),
|
||||
("import a b c", &["after `import a`, expected end of input", "import <zip-path>"]),
|
||||
("save sideways", &["after `save`, expected end of input", "save | save as"]),
|
||||
("mode sideways", &["unknown mode 'sideways'", "mode simple | mode advanced"]),
|
||||
("messages louder", &["unknown messages mode 'louder'", "messages short"]),
|
||||
("copy everything", &["unknown copy target 'everything'", "copy all"]),
|
||||
(
|
||||
"load foo",
|
||||
&["after `load`, expected end of input", " load"],
|
||||
),
|
||||
(
|
||||
"undo foo",
|
||||
&["after `undo`, expected end of input", " undo"],
|
||||
),
|
||||
(
|
||||
"redo foo",
|
||||
&["after `redo`, expected end of input", " redo"],
|
||||
),
|
||||
(
|
||||
"export foo bar",
|
||||
&[
|
||||
"after `export foo`, expected end of input",
|
||||
"export [<path>]",
|
||||
],
|
||||
),
|
||||
(
|
||||
"import a b c",
|
||||
&[
|
||||
"after `import a`, expected end of input",
|
||||
"import <zip-path>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"save sideways",
|
||||
&["after `save`, expected end of input", "save | save as"],
|
||||
),
|
||||
(
|
||||
"mode sideways",
|
||||
&["unknown mode 'sideways'", "mode simple | mode advanced"],
|
||||
),
|
||||
(
|
||||
"messages louder",
|
||||
&["unknown messages mode 'louder'", "messages short"],
|
||||
),
|
||||
(
|
||||
"copy everything",
|
||||
&["unknown copy target 'everything'", "copy all"],
|
||||
),
|
||||
// DDL bare + missing-slot
|
||||
("create", &["after `create`, expected `table`", "create table <Name> with pk"]),
|
||||
("create table", &["after `create table`, expected identifier", "create table <Name> with pk"]),
|
||||
("create table T", &["with pk", "create table <Name> with pk"]),
|
||||
(
|
||||
"create",
|
||||
&[
|
||||
"after `create`, expected `table`",
|
||||
"create table <Name> with pk",
|
||||
],
|
||||
),
|
||||
(
|
||||
"create table",
|
||||
&[
|
||||
"after `create table`, expected identifier",
|
||||
"create table <Name> with pk",
|
||||
],
|
||||
),
|
||||
(
|
||||
"create table T",
|
||||
&["with pk", "create table <Name> with pk"],
|
||||
),
|
||||
// G1: relationship cardinality reads as the named construct.
|
||||
("add", &["after `add`, expected `column`, `1:n relationship`", "add 1:n relationship"]),
|
||||
("drop table", &["after `drop table`, expected table name", "drop table <Name>"]),
|
||||
("add column", &["after `add column`, expected table name", "add column [to] [table]"]),
|
||||
("rename", &["after `rename`, expected `column`", "rename column [in] [table]"]),
|
||||
("rename column", &["after `rename column`, expected table name", "rename column [in] [table]"]),
|
||||
("change", &["after `change`, expected `column`", "change column [in] [table]"]),
|
||||
("change column", &["after `change column`, expected table name", "change column [in] [table]"]),
|
||||
(
|
||||
"add",
|
||||
&[
|
||||
"after `add`, expected `column`, `1:n relationship`",
|
||||
"add 1:n relationship",
|
||||
],
|
||||
),
|
||||
(
|
||||
"drop table",
|
||||
&[
|
||||
"after `drop table`, expected table name",
|
||||
"drop table <Name>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"add column",
|
||||
&[
|
||||
"after `add column`, expected table name",
|
||||
"add column [to] [table]",
|
||||
],
|
||||
),
|
||||
(
|
||||
"rename",
|
||||
&[
|
||||
"after `rename`, expected `column`",
|
||||
"rename column [in] [table]",
|
||||
],
|
||||
),
|
||||
(
|
||||
"rename column",
|
||||
&[
|
||||
"after `rename column`, expected table name",
|
||||
"rename column [in] [table]",
|
||||
],
|
||||
),
|
||||
(
|
||||
"change",
|
||||
&[
|
||||
"after `change`, expected `column`",
|
||||
"change column [in] [table]",
|
||||
],
|
||||
),
|
||||
(
|
||||
"change column",
|
||||
&[
|
||||
"after `change column`, expected table name",
|
||||
"change column [in] [table]",
|
||||
],
|
||||
),
|
||||
// data bare + missing-clause
|
||||
("insert", &["after `insert`, expected `into`", "insert into <Table>"]),
|
||||
("insert into", &["after `insert into`, expected table name", "insert into <Table>"]),
|
||||
("insert into T", &["after `insert into T`, expected `values` or `(`", "insert into <Table>"]),
|
||||
("update", &["after `update`, expected table name", "update <Table> set"]),
|
||||
("update T", &["after `update T`, expected `set`", "update <Table> set"]),
|
||||
("update T set x=1", &["expected `where` or `--all-rows`", "update <Table> set"]),
|
||||
("delete", &["after `delete`, expected `from`", "delete from <Table>"]),
|
||||
("delete from", &["after `delete from`, expected table name", "delete from <Table>"]),
|
||||
("delete from T", &["expected `where` or `--all-rows`", "delete from <Table>"]),
|
||||
("seed", &["after `seed`, expected table name", "seed <Table> [count]"]),
|
||||
(
|
||||
"insert",
|
||||
&["after `insert`, expected `into`", "insert into <Table>"],
|
||||
),
|
||||
(
|
||||
"insert into",
|
||||
&[
|
||||
"after `insert into`, expected table name",
|
||||
"insert into <Table>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"insert into T",
|
||||
&[
|
||||
"after `insert into T`, expected `values` or `(`",
|
||||
"insert into <Table>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"update",
|
||||
&["after `update`, expected table name", "update <Table> set"],
|
||||
),
|
||||
(
|
||||
"update T",
|
||||
&["after `update T`, expected `set`", "update <Table> set"],
|
||||
),
|
||||
(
|
||||
"update T set x=1",
|
||||
&["expected `where` or `--all-rows`", "update <Table> set"],
|
||||
),
|
||||
(
|
||||
"delete",
|
||||
&["after `delete`, expected `from`", "delete from <Table>"],
|
||||
),
|
||||
(
|
||||
"delete from",
|
||||
&[
|
||||
"after `delete from`, expected table name",
|
||||
"delete from <Table>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"delete from T",
|
||||
&["expected `where` or `--all-rows`", "delete from <Table>"],
|
||||
),
|
||||
(
|
||||
"seed",
|
||||
&["after `seed`, expected table name", "seed <Table> [count]"],
|
||||
),
|
||||
// Phase 2 (ADR-0048 D2/D1): malformed `set` clause + column-fill.
|
||||
("seed T set", &["after `seed T set`, expected column name", "seed <Table>.<col>"]),
|
||||
(
|
||||
"seed T set",
|
||||
&[
|
||||
"after `seed T set`, expected column name",
|
||||
"seed <Table>.<col>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"seed T set role",
|
||||
&["after `seed T set role`, expected `=`, `in`, `between`, or `as`", "seed <Table>.<col>"],
|
||||
&[
|
||||
"after `seed T set role`, expected `=`, `in`, `between`, or `as`",
|
||||
"seed <Table>.<col>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"seed T.",
|
||||
&[
|
||||
"after `seed T.`, expected column name",
|
||||
"seed <Table>.<col>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"replay",
|
||||
&[
|
||||
"after `replay`, expected string literal or path",
|
||||
"replay <path>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"explain",
|
||||
&[
|
||||
"after `explain`, expected `show`, `update`, or `delete`",
|
||||
"explain show data",
|
||||
],
|
||||
),
|
||||
("seed T.", &["after `seed T.`, expected column name", "seed <Table>.<col>"]),
|
||||
("replay", &["after `replay`, expected string literal or path", "replay <path>"]),
|
||||
("explain", &["after `explain`, expected `show`, `update`, or `delete`", "explain show data"]),
|
||||
// advanced-only entry word typed in simple mode → "this is SQL" rail
|
||||
("select * from T", &["`select` is SQL", "mode advanced"]),
|
||||
("alter table T add column c int", &["`alter` is SQL", "mode advanced"]),
|
||||
(
|
||||
"alter table T add column c int",
|
||||
&["`alter` is SQL", "mode advanced"],
|
||||
),
|
||||
];
|
||||
for (input, needles) in matrix {
|
||||
let lines = error_lines_for(input);
|
||||
@@ -164,26 +332,160 @@ fn near_miss_matrix_committed_multiforms() {
|
||||
// (input, advanced?, required-substrings)
|
||||
let matrix: &[(&str, bool, &[&str])] = &[
|
||||
// add / drop multi-forms (simple)
|
||||
("add index", false, &["after `add index`, expected `on` or `as`", "add index [as <Name>] on"]),
|
||||
("add index on T", false, &["after `add index on T`, expected `(`", "add index [as <Name>] on"]),
|
||||
("add constraint", false, &["after `add constraint`, expected `not`, `unique`, `default`, or `check`", "add constraint not null to"]),
|
||||
("add constraint not null", false, &["after `add constraint not null`, expected `to`", "add constraint not null to"]),
|
||||
("add 1:n relationship", false, &["after `add 1:n relationship`, expected `from` or `as`", "add 1:n relationship"]),
|
||||
("add 1:n relationship from", false, &["after `add 1:n relationship from`, expected table name", "from <Parent>.<col>"]),
|
||||
("drop constraint", false, &["after `drop constraint`, expected `not`, `unique`, `default`, or `check`", "drop constraint (not null"]),
|
||||
("drop constraint not null", false, &["after `drop constraint not null`, expected `from`", "drop constraint (not null"]),
|
||||
("drop index", false, &["after `drop index`, expected `on` or index name", "drop index <Name>", "drop index on <Table>"]),
|
||||
("drop index on T", false, &["after `drop index on T`, expected `(`", "drop index on <Table>"]),
|
||||
("drop relationship", false, &["after `drop relationship`, expected `from` or relationship name", "drop relationship <Name>"]),
|
||||
("show table", false, &["after `show table`, expected table name", "show table <Table>"]),
|
||||
("show relationship", false, &["after `show relationship`, expected relationship name", "show relationship <name>"]),
|
||||
("show index", false, &["after `show index`, expected index name", "show index <name>"]),
|
||||
("change column in table T: c", false, &["after `change column in table T: c`, expected `(`", "change column [in] [table]"]),
|
||||
(
|
||||
"add index",
|
||||
false,
|
||||
&[
|
||||
"after `add index`, expected `on` or `as`",
|
||||
"add index [as <Name>] on",
|
||||
],
|
||||
),
|
||||
(
|
||||
"add index on T",
|
||||
false,
|
||||
&[
|
||||
"after `add index on T`, expected `(`",
|
||||
"add index [as <Name>] on",
|
||||
],
|
||||
),
|
||||
(
|
||||
"add constraint",
|
||||
false,
|
||||
&[
|
||||
"after `add constraint`, expected `not`, `unique`, `default`, or `check`",
|
||||
"add constraint not null to",
|
||||
],
|
||||
),
|
||||
(
|
||||
"add constraint not null",
|
||||
false,
|
||||
&[
|
||||
"after `add constraint not null`, expected `to`",
|
||||
"add constraint not null to",
|
||||
],
|
||||
),
|
||||
(
|
||||
"add 1:n relationship",
|
||||
false,
|
||||
&[
|
||||
"after `add 1:n relationship`, expected `from` or `as`",
|
||||
"add 1:n relationship",
|
||||
],
|
||||
),
|
||||
(
|
||||
"add 1:n relationship from",
|
||||
false,
|
||||
&[
|
||||
"after `add 1:n relationship from`, expected table name",
|
||||
"from <Parent>.<col>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"drop constraint",
|
||||
false,
|
||||
&[
|
||||
"after `drop constraint`, expected `not`, `unique`, `default`, or `check`",
|
||||
"drop constraint (not null",
|
||||
],
|
||||
),
|
||||
(
|
||||
"drop constraint not null",
|
||||
false,
|
||||
&[
|
||||
"after `drop constraint not null`, expected `from`",
|
||||
"drop constraint (not null",
|
||||
],
|
||||
),
|
||||
(
|
||||
"drop index",
|
||||
false,
|
||||
&[
|
||||
"after `drop index`, expected `on` or index name",
|
||||
"drop index <Name>",
|
||||
"drop index on <Table>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"drop index on T",
|
||||
false,
|
||||
&[
|
||||
"after `drop index on T`, expected `(`",
|
||||
"drop index on <Table>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"drop relationship",
|
||||
false,
|
||||
&[
|
||||
"after `drop relationship`, expected `from` or relationship name",
|
||||
"drop relationship <Name>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"show table",
|
||||
false,
|
||||
&[
|
||||
"after `show table`, expected table name",
|
||||
"show table <Table>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"show relationship",
|
||||
false,
|
||||
&[
|
||||
"after `show relationship`, expected relationship name",
|
||||
"show relationship <name>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"show index",
|
||||
false,
|
||||
&[
|
||||
"after `show index`, expected index name",
|
||||
"show index <name>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"change column in table T: c",
|
||||
false,
|
||||
&[
|
||||
"after `change column in table T: c`, expected `(`",
|
||||
"change column [in] [table]",
|
||||
],
|
||||
),
|
||||
// advanced committed multi-forms
|
||||
("create index on", true, &["after `create index on`, expected table name", "create [unique] index"]),
|
||||
("create unique index", true, &["after `create unique index`, expected `on`, identifier, or `if`", "create [unique] index"]),
|
||||
("alter table T add", true, &["after `alter table T add`, expected `column`, `constraint`, `check`, `unique`, `foreign`, or `primary`", "alter table <Table> add column"]),
|
||||
("alter table T drop", true, &["after `alter table T drop`, expected `column` or `constraint`", "alter table <Table> drop column"]),
|
||||
(
|
||||
"create index on",
|
||||
true,
|
||||
&[
|
||||
"after `create index on`, expected table name",
|
||||
"create [unique] index",
|
||||
],
|
||||
),
|
||||
(
|
||||
"create unique index",
|
||||
true,
|
||||
&[
|
||||
"after `create unique index`, expected `on`, identifier, or `if`",
|
||||
"create [unique] index",
|
||||
],
|
||||
),
|
||||
(
|
||||
"alter table T add",
|
||||
true,
|
||||
&[
|
||||
"after `alter table T add`, expected `column`, `constraint`, `check`, `unique`, `foreign`, or `primary`",
|
||||
"alter table <Table> add column",
|
||||
],
|
||||
),
|
||||
(
|
||||
"alter table T drop",
|
||||
true,
|
||||
&[
|
||||
"after `alter table T drop`, expected `column` or `constraint`",
|
||||
"alter table <Table> drop column",
|
||||
],
|
||||
),
|
||||
];
|
||||
for (input, advanced, needles) in matrix {
|
||||
let lines = if *advanced {
|
||||
@@ -265,7 +567,10 @@ fn advanced_mode_usage_block_shows_sql_and_dsl_forms() {
|
||||
// (mode-primary first).
|
||||
let sql_at = joined.find("create table [if not exists]").unwrap();
|
||||
let dsl_at = joined.find("create table <Name> with pk").unwrap();
|
||||
assert!(sql_at < dsl_at, "SQL form should precede the DSL form\n{dump_msg}");
|
||||
assert!(
|
||||
sql_at < dsl_at,
|
||||
"SQL form should precede the DSL form\n{dump_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -307,23 +612,94 @@ fn advanced_cross_join_with_on_teaches_no_on_clause() {
|
||||
fn near_miss_matrix_advanced_mode() {
|
||||
let matrix: &[(&str, &[&str])] = &[
|
||||
// SQL select / with (G2, G4)
|
||||
("select", &["expected a projection: `*`, a column, or an expression", "select (* |"]),
|
||||
("select * from", &["after `select * from`, expected table name", "select (* |"]),
|
||||
("with", &["after `with`, expected identifier or `recursive`", "with [recursive]", "as ("]),
|
||||
(
|
||||
"select",
|
||||
&[
|
||||
"expected a projection: `*`, a column, or an expression",
|
||||
"select (* |",
|
||||
],
|
||||
),
|
||||
(
|
||||
"select * from",
|
||||
&["after `select * from`, expected table name", "select (* |"],
|
||||
),
|
||||
(
|
||||
"with",
|
||||
&[
|
||||
"after `with`, expected identifier or `recursive`",
|
||||
"with [recursive]",
|
||||
"as (",
|
||||
],
|
||||
),
|
||||
// create / drop / alter — SQL forms AND the still-valid DSL
|
||||
// fallback forms, SQL-primary first (G3).
|
||||
("create", &["after `create`, expected `table`", "create table [if not exists]", "create [unique] index", "create table <Name> with pk"]),
|
||||
("create table", &["after `create table`, expected identifier or `if`", "create table [if not exists]"]),
|
||||
("create index", &["after `create index`, expected `on`", "create [unique] index"]),
|
||||
("drop", &["after `drop`, expected `table`", "drop table [if exists]", "drop column [from]", "drop relationship"]),
|
||||
("alter", &["after `alter`, expected `table`", "alter table <Table> add column"]),
|
||||
("alter table T", &["expected `add`, `drop`, `rename`, or `alter`", "alter table <Table>"]),
|
||||
(
|
||||
"create",
|
||||
&[
|
||||
"after `create`, expected `table`",
|
||||
"create table [if not exists]",
|
||||
"create [unique] index",
|
||||
"create table <Name> with pk",
|
||||
],
|
||||
),
|
||||
(
|
||||
"create table",
|
||||
&[
|
||||
"after `create table`, expected identifier or `if`",
|
||||
"create table [if not exists]",
|
||||
],
|
||||
),
|
||||
(
|
||||
"create index",
|
||||
&[
|
||||
"after `create index`, expected `on`",
|
||||
"create [unique] index",
|
||||
],
|
||||
),
|
||||
(
|
||||
"drop",
|
||||
&[
|
||||
"after `drop`, expected `table`",
|
||||
"drop table [if exists]",
|
||||
"drop column [from]",
|
||||
"drop relationship",
|
||||
],
|
||||
),
|
||||
(
|
||||
"alter",
|
||||
&[
|
||||
"after `alter`, expected `table`",
|
||||
"alter table <Table> add column",
|
||||
],
|
||||
),
|
||||
(
|
||||
"alter table T",
|
||||
&[
|
||||
"expected `add`, `drop`, `rename`, or `alter`",
|
||||
"alter table <Table>",
|
||||
],
|
||||
),
|
||||
// shared insert/update/delete — must show usage, not the
|
||||
// available-commands fallback (regression guard for the
|
||||
// empty-usage_ids SQL nodes).
|
||||
("insert into T", &["after `insert into T`, expected `values`, `with`, `select`, or `(`", "insert into <Table>"]),
|
||||
("update T", &["after `update T`, expected `set`", "update <Table> set"]),
|
||||
("delete from", &["after `delete from`, expected table name", "delete from <Table>"]),
|
||||
(
|
||||
"insert into T",
|
||||
&[
|
||||
"after `insert into T`, expected `values`, `with`, `select`, or `(`",
|
||||
"insert into <Table>",
|
||||
],
|
||||
),
|
||||
(
|
||||
"update T",
|
||||
&["after `update T`, expected `set`", "update <Table> set"],
|
||||
),
|
||||
(
|
||||
"delete from",
|
||||
&[
|
||||
"after `delete from`, expected table name",
|
||||
"delete from <Table>",
|
||||
],
|
||||
),
|
||||
];
|
||||
for (input, needles) in matrix {
|
||||
let lines = advanced_error_lines_for(input);
|
||||
@@ -365,7 +741,9 @@ fn with_alone_renders_cte_usage_not_select() {
|
||||
.collect();
|
||||
let dump_msg = dump("with", &lines);
|
||||
assert!(
|
||||
lines.iter().any(|l| l.trim_start().starts_with("with ") && l.contains("as (")),
|
||||
lines
|
||||
.iter()
|
||||
.any(|l| l.trim_start().starts_with("with ") && l.contains("as (")),
|
||||
"missing CTE-specific `with … as (…)` usage template\n{dump_msg}",
|
||||
);
|
||||
}
|
||||
@@ -383,7 +761,9 @@ fn create_alone_renders_create_table_usage() {
|
||||
"missing usage: header\n{dump_msg}",
|
||||
);
|
||||
assert!(
|
||||
lines.iter().any(|l| l.contains("create table") && l.contains("with pk")),
|
||||
lines
|
||||
.iter()
|
||||
.any(|l| l.contains("create table") && l.contains("with pk")),
|
||||
"missing create_table usage template\n{dump_msg}",
|
||||
);
|
||||
}
|
||||
@@ -464,8 +844,7 @@ fn unknown_command_falls_back_to_available_commands_list() {
|
||||
.unwrap_or_else(|| panic!("missing available commands line\n{dump_msg}"));
|
||||
// The list must include all ten command-entry keywords.
|
||||
for cmd in [
|
||||
"add", "change", "create", "delete", "drop", "insert",
|
||||
"rename", "replay", "show", "update",
|
||||
"add", "change", "create", "delete", "drop", "insert", "rename", "replay", "show", "update",
|
||||
] {
|
||||
assert!(
|
||||
available.contains(&format!("`{cmd}`")),
|
||||
@@ -543,8 +922,3 @@ fn caret_aligns_under_offending_token() {
|
||||
"caret should sit at column 9 (under `f` of `frobulate` after the `running: ` prefix); got {leading_spaces} spaces in {caret:?}",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -22,14 +22,16 @@ fn tempdir() -> tempfile::TempDir {
|
||||
#[test]
|
||||
fn no_args_creates_temp_project_under_data_root() {
|
||||
let data = tempdir();
|
||||
let project = project::open_or_create(None, Some(data.path()))
|
||||
.expect("open_or_create with empty CLI");
|
||||
let project =
|
||||
project::open_or_create(None, Some(data.path())).expect("open_or_create with empty CLI");
|
||||
|
||||
let path = project.path();
|
||||
assert!(path.exists(), "project dir should exist");
|
||||
assert!(path.starts_with(data.path()));
|
||||
assert_eq!(
|
||||
path.parent().and_then(|p| p.file_name()).map(|s| s.to_string_lossy().into_owned()),
|
||||
path.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|s| s.to_string_lossy().into_owned()),
|
||||
Some(PROJECTS_SUBDIR.to_string()),
|
||||
);
|
||||
|
||||
@@ -96,8 +98,7 @@ fn positional_path_opens_existing_project() {
|
||||
|
||||
// Now drive open_or_create with the path as if it were a
|
||||
// CLI positional argument.
|
||||
let project = project::open_or_create(Some(&path), None)
|
||||
.expect("open via positional path");
|
||||
let project = project::open_or_create(Some(&path), None).expect("open via positional path");
|
||||
assert_eq!(project.path(), path);
|
||||
}
|
||||
|
||||
@@ -142,7 +143,10 @@ fn data_dir_override_does_not_touch_default_os_dir() {
|
||||
|
||||
assert!(p1_path.starts_with(data.path()));
|
||||
assert!(p2_path.starts_with(data.path()));
|
||||
assert_ne!(p1_path, p2_path, "two temp projects must have distinct names");
|
||||
assert_ne!(
|
||||
p1_path, p2_path,
|
||||
"two temp projects must have distinct names"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -167,11 +171,18 @@ fn db_persists_across_open_close_cycles() {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
vec![
|
||||
rdbms_playground::dsl::ColumnSpec::new("id".to_string(), rdbms_playground::dsl::Type::Serial),
|
||||
rdbms_playground::dsl::ColumnSpec::new("Name".to_string(), rdbms_playground::dsl::Type::Text),
|
||||
rdbms_playground::dsl::ColumnSpec::new(
|
||||
"id".to_string(),
|
||||
rdbms_playground::dsl::Type::Serial,
|
||||
),
|
||||
rdbms_playground::dsl::ColumnSpec::new(
|
||||
"Name".to_string(),
|
||||
rdbms_playground::dsl::Type::Text,
|
||||
),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None)
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create_table");
|
||||
});
|
||||
@@ -187,7 +198,9 @@ fn db_persists_across_open_close_cycles() {
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let tables = rt.block_on(async { db.list_tables().await }).expect("list_tables");
|
||||
let tables = rt
|
||||
.block_on(async { db.list_tables().await })
|
||||
.expect("list_tables");
|
||||
assert!(tables.iter().any(|t| t == "Customers"), "got: {tables:?}");
|
||||
|
||||
// Sanity: the project.yaml and history.log are still empty
|
||||
|
||||
+61
-52
@@ -47,8 +47,7 @@ fn tempdir() -> tempfile::TempDir {
|
||||
/// harness — most tests only need to write a script file and
|
||||
/// call `run_replay`.
|
||||
fn open_project_db(data_root: &Path) -> (project::Project, Database) {
|
||||
let project = project::open_or_create(None, Some(data_root))
|
||||
.expect("open_or_create");
|
||||
let project = project::open_or_create(None, Some(data_root)).expect("open_or_create");
|
||||
let db = Database::open_with_persistence(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
@@ -132,9 +131,7 @@ fn replay_three_lines_dispatches_three_commands() {
|
||||
insert into T (1, 'Alice')\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "seed.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
|
||||
assert_completed(&events, 3);
|
||||
|
||||
// The dispatched commands actually mutated state.
|
||||
@@ -167,8 +164,7 @@ fn replay_of_actual_history_log_runs_ok_commands_and_skips_err() {
|
||||
2026-05-24T10:00:03Z|ok|insert into T (id, v) values (1, 'alpha')\n",
|
||||
);
|
||||
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
// Three `ok` records replayed; the `err` record is skipped (not
|
||||
// counted, not a failure).
|
||||
assert_completed(&events, 3);
|
||||
@@ -215,14 +211,21 @@ fn replay_skips_app_lifecycle_commands_silently() {
|
||||
2026-05-24T10:00:13Z|ok|add column T: v (text)\n\
|
||||
2026-05-24T10:00:14Z|ok|insert into T (id, v) values (1, 'alpha')\n",
|
||||
);
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
// Three data/schema commands ran; every app-lifecycle line was
|
||||
// skipped silently (no panic, no abort, no warnings, no quit).
|
||||
match events.last().expect("event") {
|
||||
AppEvent::ReplayCompleted { count, warnings, .. } => {
|
||||
assert_eq!(*count, 3, "only the 3 write commands ran; events: {events:?}");
|
||||
assert!(warnings.is_empty(), "these skips are silent; got {warnings:?}");
|
||||
AppEvent::ReplayCompleted {
|
||||
count, warnings, ..
|
||||
} => {
|
||||
assert_eq!(
|
||||
*count, 3,
|
||||
"only the 3 write commands ran; events: {events:?}"
|
||||
);
|
||||
assert!(
|
||||
warnings.is_empty(),
|
||||
"these skips are silent; got {warnings:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected ReplayCompleted, got {other:?}"),
|
||||
}
|
||||
@@ -251,10 +254,11 @@ fn replay_skips_import_with_a_warning() {
|
||||
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)\n\
|
||||
2026-05-24T10:00:01Z|ok|import shared.zip as Imported\n",
|
||||
);
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
match events.last().expect("event") {
|
||||
AppEvent::ReplayCompleted { count, warnings, .. } => {
|
||||
AppEvent::ReplayCompleted {
|
||||
count, warnings, ..
|
||||
} => {
|
||||
assert_eq!(*count, 1, "only the create ran; events: {events:?}");
|
||||
assert!(
|
||||
warnings.iter().any(|w| w.contains("import shared.zip")),
|
||||
@@ -282,9 +286,7 @@ fn replay_skips_blank_lines_and_comments() {
|
||||
\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "seed.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
|
||||
// Only two non-blank, non-comment lines.
|
||||
assert_completed(&events, 2);
|
||||
}
|
||||
@@ -295,9 +297,7 @@ fn replay_empty_file_completes_with_zero_commands() {
|
||||
let (project, db) = open_project_db(data.path());
|
||||
write_script(project.path(), "empty.commands", "");
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "empty.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "empty.commands").await });
|
||||
assert_completed(&events, 0);
|
||||
}
|
||||
|
||||
@@ -311,9 +311,8 @@ fn replay_only_comments_completes_with_zero_commands() {
|
||||
"# just\n# comments\n\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "comments.commands").await
|
||||
});
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "comments.commands").await });
|
||||
assert_completed(&events, 0);
|
||||
}
|
||||
|
||||
@@ -350,8 +349,14 @@ fn replay_constraint_failure_shows_real_names_not_placeholders() {
|
||||
// INSERT command — the **real offending value** is shown too (it used
|
||||
// to degrade to the neutral "that value" because `SqlInsert` discarded
|
||||
// its literals).
|
||||
assert!(error.contains("T.email"), "names the real table.column; got: {error}");
|
||||
assert!(error.contains("a@b.com"), "shows the real offending value; got: {error}");
|
||||
assert!(
|
||||
error.contains("T.email"),
|
||||
"names the real table.column; got: {error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("a@b.com"),
|
||||
"shows the real offending value; got: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -359,9 +364,8 @@ fn replay_missing_file_fails_with_line_number_zero() {
|
||||
let data = tempdir();
|
||||
let (project, db) = open_project_db(data.path());
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "no-such-file.commands").await
|
||||
});
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "no-such-file.commands").await });
|
||||
let failed = assert_failed_at(&events, 0);
|
||||
let AppEvent::ReplayFailed { error, .. } = failed else {
|
||||
unreachable!()
|
||||
@@ -387,9 +391,7 @@ fn replay_aborts_on_first_parse_failure_and_reports_line() {
|
||||
insert into T (1, 'should not happen')\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "bad.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "bad.commands").await });
|
||||
let failed = assert_failed_at(&events, 3);
|
||||
let AppEvent::ReplayFailed { error, command, .. } = failed else {
|
||||
unreachable!()
|
||||
@@ -452,9 +454,7 @@ fn replay_rejects_wrong_type_value_in_a_hand_built_script() {
|
||||
insert into T values (1, 'not a number')\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "typed.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "typed.commands").await });
|
||||
let failed = assert_failed_at(&events, 3);
|
||||
let AppEvent::ReplayFailed { error, .. } = failed else {
|
||||
unreachable!()
|
||||
@@ -489,9 +489,7 @@ fn replay_aborts_on_first_runtime_failure_and_reports_line() {
|
||||
insert into T (1)\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "bad.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "bad.commands").await });
|
||||
let _ = assert_failed_at(&events, 2);
|
||||
}
|
||||
|
||||
@@ -504,23 +502,29 @@ fn replay_skips_nested_replay_with_a_warning() {
|
||||
// because the nested file's commands are not reconstructed.
|
||||
let data = tempdir();
|
||||
let (project, db) = open_project_db(data.path());
|
||||
write_script(project.path(), "inner.commands", "create table T with pk id(int)\n");
|
||||
write_script(
|
||||
project.path(),
|
||||
"inner.commands",
|
||||
"create table T with pk id(int)\n",
|
||||
);
|
||||
write_script(
|
||||
project.path(),
|
||||
"outer.commands",
|
||||
"create table U with pk id(int)\nreplay inner.commands\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "outer.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "outer.commands").await });
|
||||
// The outer `create table U` ran; the nested `replay` was
|
||||
// skipped (count 1), with a warning.
|
||||
match events.last().expect("event") {
|
||||
AppEvent::ReplayCompleted { count, warnings, .. } => {
|
||||
AppEvent::ReplayCompleted {
|
||||
count, warnings, ..
|
||||
} => {
|
||||
assert_eq!(*count, 1, "only the outer create ran; events: {events:?}");
|
||||
assert!(
|
||||
warnings.iter().any(|w| w.contains("nested") && w.contains("replay inner.commands")),
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("nested") && w.contains("replay inner.commands")),
|
||||
"expected a nested-replay skip warning; got {warnings:?}",
|
||||
);
|
||||
}
|
||||
@@ -528,7 +532,10 @@ fn replay_skips_nested_replay_with_a_warning() {
|
||||
}
|
||||
// The nested file's table was NOT created (the replay was skipped).
|
||||
let cols = rt().block_on(async { db.query_data("T".to_string(), None, None).await });
|
||||
assert!(cols.is_err(), "inner.commands' table T must not exist (nested replay skipped)");
|
||||
assert!(
|
||||
cols.is_err(),
|
||||
"inner.commands' table T must not exist (nested replay skipped)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -546,20 +553,22 @@ fn replay_history_log_records_subcommands_only() {
|
||||
"create table T with pk id(int)\nadd column T: name (text)\n",
|
||||
);
|
||||
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "seed.commands").await
|
||||
});
|
||||
let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
|
||||
assert_completed(&events, 2);
|
||||
|
||||
let history = fs::read_to_string(project.path().join("history.log"))
|
||||
.expect("history.log exists");
|
||||
let history =
|
||||
fs::read_to_string(project.path().join("history.log")).expect("history.log exists");
|
||||
// Per-command entries landed.
|
||||
assert!(
|
||||
history.lines().any(|l| l.contains("create table T with pk id(int)")),
|
||||
history
|
||||
.lines()
|
||||
.any(|l| l.contains("create table T with pk id(int)")),
|
||||
"history.log missing create line:\n{history}"
|
||||
);
|
||||
assert!(
|
||||
history.lines().any(|l| l.contains("add column T: name (text)")),
|
||||
history
|
||||
.lines()
|
||||
.any(|l| l.contains("add column T: name (text)")),
|
||||
"history.log missing add column line:\n{history}"
|
||||
);
|
||||
// The replay invocation itself did NOT land — that's
|
||||
|
||||
+358
-75
@@ -18,8 +18,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence(project.db_path(), persistence)
|
||||
.expect("open db with persistence");
|
||||
@@ -76,7 +75,10 @@ fn seed_parses_with_and_without_count() {
|
||||
match parse_command("seed People").expect("`seed People` parses") {
|
||||
Command::Seed { table, count, .. } => {
|
||||
assert_eq!(table, "People");
|
||||
assert_eq!(count, None, "omitted count is None (executor defaults to 20)");
|
||||
assert_eq!(
|
||||
count, None,
|
||||
"omitted count is None (executor defaults to 20)"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Command::Seed, got {other:?}"),
|
||||
}
|
||||
@@ -134,7 +136,10 @@ fn seed_set_fixed_value_override_parses() {
|
||||
let (_t, ov) = seed_overrides("seed users 5 set status = 'active'");
|
||||
assert_eq!(ov.len(), 1);
|
||||
assert_eq!(ov[0].column, "status");
|
||||
assert_eq!(ov[0].kind, SeedOverrideKind::Fixed(Value::Text("active".into())));
|
||||
assert_eq!(
|
||||
ov[0].kind,
|
||||
SeedOverrideKind::Fixed(Value::Text("active".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -177,8 +182,7 @@ fn seed_set_numeric_range_override_parses() {
|
||||
#[test]
|
||||
fn seed_set_date_range_override_parses_with_quoted_dates() {
|
||||
// ADR-0048 D2 amendment: dates in the range form are quoted strings.
|
||||
let (_t, ov) =
|
||||
seed_overrides("seed users set signup between '2023-01-01' and '2024-12-31'");
|
||||
let (_t, ov) = seed_overrides("seed users set signup between '2023-01-01' and '2024-12-31'");
|
||||
assert_eq!(
|
||||
ov[0].kind,
|
||||
SeedOverrideKind::Range {
|
||||
@@ -207,7 +211,9 @@ fn seed_count_is_not_confused_by_a_range_value() {
|
||||
// No positional count, but `between 18 and 80` carries NumberLits —
|
||||
// they must not be read as the count (bounded to before `set`).
|
||||
match parse_command("seed users set age between 18 and 80").expect("parses") {
|
||||
Command::Seed { count, overrides, .. } => {
|
||||
Command::Seed {
|
||||
count, overrides, ..
|
||||
} => {
|
||||
assert_eq!(count, None, "the count is None, not 18");
|
||||
assert_eq!(overrides.len(), 1);
|
||||
}
|
||||
@@ -267,7 +273,14 @@ fn seed_populates_a_table_and_persists_rows() {
|
||||
create_people(&db, &rt);
|
||||
|
||||
let result = rt
|
||||
.block_on(db.seed("People".into(), None, Some(7), Vec::new(), Some(42), Some("seed People 7".into())))
|
||||
.block_on(db.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
Some(7),
|
||||
Vec::new(),
|
||||
Some(42),
|
||||
Some("seed People 7".into()),
|
||||
))
|
||||
.expect("seed succeeds");
|
||||
assert_eq!(result.produced, 7);
|
||||
|
||||
@@ -278,22 +291,34 @@ fn seed_populates_a_table_and_persists_rows() {
|
||||
"CSV should hold 7 generated rows:\n{csv}"
|
||||
);
|
||||
// The generated `email` column produces address-shaped values.
|
||||
assert!(csv.contains('@'), "seeded emails should appear in the CSV:\n{csv}");
|
||||
assert!(
|
||||
csv.contains('@'),
|
||||
"seeded emails should appear in the CSV:\n{csv}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a seeded table's CSV into per-column value lists (simple
|
||||
/// comma-split — the values under test carry no commas/quotes).
|
||||
fn csv_columns(csv: &str) -> (Vec<String>, Vec<Vec<String>>) {
|
||||
let mut lines = csv.lines().filter(|l| !l.trim().is_empty());
|
||||
let header: Vec<String> = lines.next().unwrap().split(',').map(str::to_string).collect();
|
||||
let rows: Vec<Vec<String>> =
|
||||
lines.map(|l| l.split(',').map(str::to_string).collect()).collect();
|
||||
let header: Vec<String> = lines
|
||||
.next()
|
||||
.unwrap()
|
||||
.split(',')
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let rows: Vec<Vec<String>> = lines
|
||||
.map(|l| l.split(',').map(str::to_string).collect())
|
||||
.collect();
|
||||
(header, rows)
|
||||
}
|
||||
|
||||
fn column_values(csv: &str, col: &str) -> Vec<String> {
|
||||
let (header, rows) = csv_columns(csv);
|
||||
let idx = header.iter().position(|h| h == col).expect("column present");
|
||||
let idx = header
|
||||
.iter()
|
||||
.position(|h| h == col)
|
||||
.expect("column present");
|
||||
rows.iter().map(|r| r[idx].clone()).collect()
|
||||
}
|
||||
|
||||
@@ -321,20 +346,36 @@ fn seed_year_and_choice_set_heuristics() {
|
||||
))
|
||||
.expect("create Records");
|
||||
|
||||
rt.block_on(db.seed("Records".into(), None, Some(30), Vec::new(), Some(99), Some("seed Records 30".into())))
|
||||
rt.block_on(db.seed(
|
||||
"Records".into(),
|
||||
None,
|
||||
Some(30),
|
||||
Vec::new(),
|
||||
Some(99),
|
||||
Some("seed Records 30".into()),
|
||||
))
|
||||
.expect("seed succeeds");
|
||||
let csv = read_csv(&project, "Records").expect("Records CSV exists");
|
||||
|
||||
for y in column_values(&csv, "birth_year") {
|
||||
let n: i32 = y.parse().expect("birth_year is an int");
|
||||
assert!((1945..=2007).contains(&n), "birth_year {n} must be a plausible birth year");
|
||||
assert!(
|
||||
(1945..=2007).contains(&n),
|
||||
"birth_year {n} must be a plausible birth year"
|
||||
);
|
||||
}
|
||||
for y in column_values(&csv, "published") {
|
||||
let n: i32 = y.parse().expect("published is an int");
|
||||
assert!((1950..=2025).contains(&n), "published {n} must be a plausible recent year");
|
||||
assert!(
|
||||
(1950..=2025).contains(&n),
|
||||
"published {n} must be a plausible recent year"
|
||||
);
|
||||
}
|
||||
for p in column_values(&csv, "priority") {
|
||||
assert!(["low", "medium", "high"].contains(&p.as_str()), "priority `{p}` must be low/medium/high");
|
||||
assert!(
|
||||
["low", "medium", "high"].contains(&p.as_str()),
|
||||
"priority `{p}` must be low/medium/high"
|
||||
);
|
||||
}
|
||||
for s in column_values(&csv, "severity") {
|
||||
assert!(
|
||||
@@ -405,7 +446,14 @@ fn seed_count_defaults_to_twenty() {
|
||||
create_people(&db, &rt);
|
||||
|
||||
let result = rt
|
||||
.block_on(db.seed("People".into(), None, None, Vec::new(), Some(1), Some("seed People".into())))
|
||||
.block_on(db.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
None,
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed People".into()),
|
||||
))
|
||||
.expect("seed succeeds");
|
||||
assert_eq!(result.produced, 20, "omitted count defaults to 20");
|
||||
let csv = read_csv(&project, "People").expect("People CSV exists");
|
||||
@@ -420,9 +468,23 @@ fn seed_is_reproducible_with_a_fixed_seed() {
|
||||
create_people(&db1, &rt);
|
||||
create_people(&db2, &rt);
|
||||
|
||||
rt.block_on(db1.seed("People".into(), None, Some(4), Vec::new(), Some(123), Some("seed People 4".into())))
|
||||
rt.block_on(db1.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
Some(4),
|
||||
Vec::new(),
|
||||
Some(123),
|
||||
Some("seed People 4".into()),
|
||||
))
|
||||
.expect("seed run 1");
|
||||
rt.block_on(db2.seed("People".into(), None, Some(4), Vec::new(), Some(123), Some("seed People 4".into())))
|
||||
rt.block_on(db2.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
Some(4),
|
||||
Vec::new(),
|
||||
Some(123),
|
||||
Some("seed People 4".into()),
|
||||
))
|
||||
.expect("seed run 2");
|
||||
|
||||
let csv1 = read_csv(&p1, "People").expect("csv 1");
|
||||
@@ -493,10 +555,24 @@ fn seed_fills_foreign_keys_from_existing_parents() {
|
||||
create_users_and_orders(&db, &rt, true);
|
||||
|
||||
// 5 parents → serial ids 1..=5.
|
||||
rt.block_on(db.seed("Users".into(), None, Some(5), Vec::new(), Some(1), Some("seed Users 5".into())))
|
||||
rt.block_on(db.seed(
|
||||
"Users".into(),
|
||||
None,
|
||||
Some(5),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Users 5".into()),
|
||||
))
|
||||
.expect("seed Users");
|
||||
let res = rt
|
||||
.block_on(db.seed("Orders".into(), None, Some(10), Vec::new(), Some(2), Some("seed Orders 10".into())))
|
||||
.block_on(db.seed(
|
||||
"Orders".into(),
|
||||
None,
|
||||
Some(10),
|
||||
Vec::new(),
|
||||
Some(2),
|
||||
Some("seed Orders 10".into()),
|
||||
))
|
||||
.expect("seed Orders");
|
||||
assert_eq!(res.produced, 10, "every child row must insert (valid FK)");
|
||||
|
||||
@@ -520,10 +596,20 @@ fn seed_refuses_when_a_parent_table_is_empty() {
|
||||
|
||||
// Users is empty — no valid FK can be fabricated.
|
||||
let err = rt
|
||||
.block_on(db.seed("Orders".into(), None, Some(3), Vec::new(), Some(1), Some("seed Orders 3".into())))
|
||||
.block_on(db.seed(
|
||||
"Orders".into(),
|
||||
None,
|
||||
Some(3),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Orders 3".into()),
|
||||
))
|
||||
.expect_err("seed must refuse an empty parent");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("Users"), "error should name the empty parent: {msg}");
|
||||
assert!(
|
||||
msg.contains("Users"),
|
||||
"error should name the empty parent: {msg}"
|
||||
);
|
||||
let lower = msg.to_lowercase();
|
||||
assert!(
|
||||
lower.contains("no rows") || lower.contains("first"),
|
||||
@@ -546,7 +632,14 @@ fn seed_refuses_a_not_null_blob_column() {
|
||||
.expect("create Files");
|
||||
|
||||
let err = rt
|
||||
.block_on(db.seed("Files".into(), None, Some(2), Vec::new(), Some(1), Some("seed Files 2".into())))
|
||||
.block_on(db.seed(
|
||||
"Files".into(),
|
||||
None,
|
||||
Some(2),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Files 2".into()),
|
||||
))
|
||||
.expect_err("seed must refuse a NOT NULL blob");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
@@ -573,7 +666,14 @@ fn seed_omits_a_nullable_blob_column() {
|
||||
.expect("create Files");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Files".into(), None, Some(3), Vec::new(), Some(1), Some("seed Files 3".into())))
|
||||
.block_on(db.seed(
|
||||
"Files".into(),
|
||||
None,
|
||||
Some(3),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Files 3".into()),
|
||||
))
|
||||
.expect("seed succeeds despite the nullable blob");
|
||||
assert_eq!(res.produced, 3);
|
||||
let csv = read_csv(&project, "Files").expect("Files CSV");
|
||||
@@ -607,14 +707,25 @@ fn seed_keeps_unique_columns_distinct() {
|
||||
.expect("create Tags");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Tags".into(), None, Some(8), Vec::new(), Some(3), Some("seed Tags 8".into())))
|
||||
.block_on(db.seed(
|
||||
"Tags".into(),
|
||||
None,
|
||||
Some(8),
|
||||
Vec::new(),
|
||||
Some(3),
|
||||
Some("seed Tags 8".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
assert_eq!(res.produced, 8);
|
||||
|
||||
let csv = read_csv(&project, "Tags").expect("Tags CSV");
|
||||
let labels = nth_column_values(&csv, 1);
|
||||
let distinct: std::collections::HashSet<&String> = labels.iter().collect();
|
||||
assert_eq!(distinct.len(), labels.len(), "UNIQUE column has duplicates:\n{csv}");
|
||||
assert_eq!(
|
||||
distinct.len(),
|
||||
labels.len(),
|
||||
"UNIQUE column has duplicates:\n{csv}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -636,7 +747,14 @@ fn seed_sequences_identifier_int_columns() {
|
||||
.expect("create Items");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Items".into(), None, Some(5), Vec::new(), Some(1), Some("seed Items 5".into())))
|
||||
.block_on(db.seed(
|
||||
"Items".into(),
|
||||
None,
|
||||
Some(5),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Items 5".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
assert_eq!(res.produced, 5);
|
||||
|
||||
@@ -646,7 +764,11 @@ fn seed_sequences_identifier_int_columns() {
|
||||
.map(|s| s.parse().expect("code is an int"))
|
||||
.collect();
|
||||
let distinct: std::collections::HashSet<i64> = codes.iter().copied().collect();
|
||||
assert_eq!(distinct.len(), 5, "identifier ints must be unique: {codes:?}");
|
||||
assert_eq!(
|
||||
distinct.len(),
|
||||
5,
|
||||
"identifier ints must be unique: {codes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -667,14 +789,24 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
|
||||
)
|
||||
.await
|
||||
.expect("create parent");
|
||||
db.seed(t.into(), None, Some(2), Vec::new(), Some(1), Some(format!("seed {t} 2")))
|
||||
db.seed(
|
||||
t.into(),
|
||||
None,
|
||||
Some(2),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some(format!("seed {t} 2")),
|
||||
)
|
||||
.await
|
||||
.expect("seed parent");
|
||||
}
|
||||
// Junction with a compound PK over its two FK columns.
|
||||
db.create_table(
|
||||
"J".to_string(),
|
||||
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
|
||||
vec![
|
||||
ColumnSpec::new("a", Type::Int),
|
||||
ColumnSpec::new("b", Type::Int),
|
||||
],
|
||||
vec!["a".to_string(), "b".to_string()],
|
||||
None,
|
||||
)
|
||||
@@ -709,11 +841,21 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
|
||||
|
||||
// Requesting 10 caps at the 4 available distinct combinations.
|
||||
let res = db
|
||||
.seed("J".into(), None, Some(10), Vec::new(), Some(7), Some("seed J 10".into()))
|
||||
.seed(
|
||||
"J".into(),
|
||||
None,
|
||||
Some(10),
|
||||
Vec::new(),
|
||||
Some(7),
|
||||
Some("seed J 10".into()),
|
||||
)
|
||||
.await
|
||||
.expect("seed J");
|
||||
assert_eq!(res.produced, 4, "junction caps at available combos");
|
||||
assert_eq!(res.requested, 10, "the requested count is reported for the cap note");
|
||||
assert_eq!(
|
||||
res.requested, 10,
|
||||
"the requested count is reported for the cap note"
|
||||
);
|
||||
});
|
||||
|
||||
let csv = read_csv(&project, "J").expect("J CSV");
|
||||
@@ -724,7 +866,11 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let distinct: std::collections::HashSet<&String> = pairs.iter().collect();
|
||||
assert_eq!(distinct.len(), pairs.len(), "junction rows must be distinct:\n{csv}");
|
||||
assert_eq!(
|
||||
distinct.len(),
|
||||
pairs.len(),
|
||||
"junction rows must be distinct:\n{csv}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -743,9 +889,19 @@ fn seed_draws_enum_values_from_an_in_check() {
|
||||
|
||||
// Every generated status must satisfy the CHECK, so all rows insert.
|
||||
let res = rt
|
||||
.block_on(db.seed("Tickets".into(), None, Some(12), Vec::new(), Some(2), Some("seed Tickets 12".into())))
|
||||
.block_on(db.seed(
|
||||
"Tickets".into(),
|
||||
None,
|
||||
Some(12),
|
||||
Vec::new(),
|
||||
Some(2),
|
||||
Some("seed Tickets 12".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
assert_eq!(res.produced, 12, "all rows insert — values satisfy the CHECK");
|
||||
assert_eq!(
|
||||
res.produced, 12,
|
||||
"all rows insert — values satisfy the CHECK"
|
||||
);
|
||||
|
||||
let csv = read_csv(&project, "Tickets").expect("Tickets CSV");
|
||||
for v in nth_column_values(&csv, 1) {
|
||||
@@ -780,7 +936,14 @@ fn seed_advises_on_enum_ish_columns() {
|
||||
.expect("create Tasks");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Tasks".into(), None, Some(3), Vec::new(), Some(1), Some("seed Tasks 3".into())))
|
||||
.block_on(db.seed(
|
||||
"Tasks".into(),
|
||||
None,
|
||||
Some(3),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Tasks 3".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
assert!(
|
||||
res.advisory_columns.contains(&"status".to_string()),
|
||||
@@ -795,7 +958,14 @@ fn seed_refuses_an_excessive_count() {
|
||||
let rt = rt();
|
||||
create_people(&db, &rt);
|
||||
let err = rt
|
||||
.block_on(db.seed("People".into(), None, Some(1_000_000), Vec::new(), Some(1), Some("seed People 1000000".into())))
|
||||
.block_on(db.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
Some(1_000_000),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed People 1000000".into()),
|
||||
))
|
||||
.expect_err("an excessive count must be refused");
|
||||
assert!(
|
||||
err.to_string().to_lowercase().contains("maximum"),
|
||||
@@ -810,7 +980,14 @@ fn seed_preview_is_capped_but_count_is_full() {
|
||||
create_people(&db, &rt);
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("People".into(), None, Some(25), Vec::new(), Some(1), Some("seed People 25".into())))
|
||||
.block_on(db.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
Some(25),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed People 25".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
assert_eq!(res.produced, 25, "the full count is produced");
|
||||
assert_eq!(res.data.rows.len(), 20, "the preview is capped at 20 rows");
|
||||
@@ -860,14 +1037,24 @@ fn seed_is_one_undo_step() {
|
||||
.expect("open db with undo");
|
||||
let rt = rt();
|
||||
create_people(&db, &rt);
|
||||
rt.block_on(db.seed("People".into(), None, Some(6), Vec::new(), Some(1), Some("seed People 6".into())))
|
||||
rt.block_on(db.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
Some(6),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed People 6".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
assert_eq!(data_row_count(&read_csv(&project, "People").unwrap()), 6);
|
||||
|
||||
// One undo removes the whole seed batch (ADR-0048 D15).
|
||||
rt.block_on(db.undo()).unwrap().expect("undo applied");
|
||||
let rows = read_csv(&project, "People").map_or(0, |c| data_row_count(&c));
|
||||
assert_eq!(rows, 0, "one undo must remove every seeded row in a single step");
|
||||
assert_eq!(
|
||||
rows, 0,
|
||||
"one undo must remove every seeded row in a single step"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -882,10 +1069,17 @@ fn seed_column_fill_is_one_undo_step() {
|
||||
create_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 5 --seed 1").expect("seed");
|
||||
// Fill `status` across all 5 rows with a constant, then undo once.
|
||||
run_seed(&db, &rt, "seed Members.status set status = 'flagged' --seed 2")
|
||||
run_seed(
|
||||
&db,
|
||||
&rt,
|
||||
"seed Members.status set status = 'flagged' --seed 2",
|
||||
)
|
||||
.expect("column-fill");
|
||||
let before = named_column_values(&read_csv(&project, "Members").unwrap(), "status");
|
||||
assert!(before.iter().all(|s| s == "flagged"), "all rows filled: {before:?}");
|
||||
assert!(
|
||||
before.iter().all(|s| s == "flagged"),
|
||||
"all rows filled: {before:?}"
|
||||
);
|
||||
|
||||
rt.block_on(db.undo()).unwrap().expect("undo applied");
|
||||
let after = named_column_values(&read_csv(&project, "Members").unwrap(), "status");
|
||||
@@ -893,7 +1087,11 @@ fn seed_column_fill_is_one_undo_step() {
|
||||
after.iter().all(|s| s != "flagged"),
|
||||
"one undo reverts the whole column-fill in a single step: {after:?}"
|
||||
);
|
||||
assert_eq!(after.len(), 5, "undo restores the original rows, not removes them");
|
||||
assert_eq!(
|
||||
after.len(),
|
||||
5,
|
||||
"undo restores the original rows, not removes them"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -930,10 +1128,23 @@ fn seed_rolls_back_atomically_on_a_constraint_failure() {
|
||||
))
|
||||
.expect("create Bad");
|
||||
|
||||
let res = rt.block_on(db.seed("Bad".into(), None, Some(5), Vec::new(), Some(1), Some("seed Bad 5".into())));
|
||||
assert!(res.is_err(), "seed must fail when generated rows violate the CHECK");
|
||||
let res = rt.block_on(db.seed(
|
||||
"Bad".into(),
|
||||
None,
|
||||
Some(5),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Bad 5".into()),
|
||||
));
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"seed must fail when generated rows violate the CHECK"
|
||||
);
|
||||
let rows = read_csv(&project, "Bad").map_or(0, |c| data_row_count(&c));
|
||||
assert_eq!(rows, 0, "a failed seed must leave the table unchanged (atomic)");
|
||||
assert_eq!(
|
||||
rows, 0,
|
||||
"a failed seed must leave the table unchanged (atomic)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -942,7 +1153,14 @@ fn seed_zero_is_a_no_op() {
|
||||
let rt = rt();
|
||||
create_people(&db, &rt);
|
||||
let res = rt
|
||||
.block_on(db.seed("People".into(), None, Some(0), Vec::new(), Some(1), Some("seed People 0".into())))
|
||||
.block_on(db.seed(
|
||||
"People".into(),
|
||||
None,
|
||||
Some(0),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed People 0".into()),
|
||||
))
|
||||
.expect("seed 0 succeeds");
|
||||
assert_eq!(res.produced, 0);
|
||||
let rows = read_csv(&project, "People").map_or(0, |c| data_row_count(&c));
|
||||
@@ -967,7 +1185,14 @@ fn seed_advises_on_a_complex_check_column() {
|
||||
.expect("create Widgets");
|
||||
|
||||
let res = rt
|
||||
.block_on(db.seed("Widgets".into(), None, Some(3), Vec::new(), Some(1), Some("seed Widgets 3".into())))
|
||||
.block_on(db.seed(
|
||||
"Widgets".into(),
|
||||
None,
|
||||
Some(3),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Widgets 3".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
assert!(
|
||||
res.advisory_columns.contains(&"label".to_string()),
|
||||
@@ -981,9 +1206,23 @@ fn seed_foreign_keys_are_reproducible_with_a_fixed_seed() {
|
||||
let rt = rt();
|
||||
let seed_one = |db: &Database| {
|
||||
create_users_and_orders(db, &rt, true);
|
||||
rt.block_on(db.seed("Users".into(), None, Some(4), Vec::new(), Some(1), Some("seed Users 4".into())))
|
||||
rt.block_on(db.seed(
|
||||
"Users".into(),
|
||||
None,
|
||||
Some(4),
|
||||
Vec::new(),
|
||||
Some(1),
|
||||
Some("seed Users 4".into()),
|
||||
))
|
||||
.expect("seed users");
|
||||
rt.block_on(db.seed("Orders".into(), None, Some(8), Vec::new(), Some(99), Some("seed Orders 8".into())))
|
||||
rt.block_on(db.seed(
|
||||
"Orders".into(),
|
||||
None,
|
||||
Some(8),
|
||||
Vec::new(),
|
||||
Some(99),
|
||||
Some("seed Orders 8".into()),
|
||||
))
|
||||
.expect("seed orders");
|
||||
};
|
||||
let (p1, db1, _d1) = open_project_db();
|
||||
@@ -1013,7 +1252,14 @@ fn seed_shortid_columns_are_reproducible_with_a_fixed_seed() {
|
||||
None,
|
||||
))
|
||||
.expect("create Contacts");
|
||||
rt.block_on(db.seed("Contacts".into(), None, Some(5), Vec::new(), Some(42), Some("seed Contacts 5".into())))
|
||||
rt.block_on(db.seed(
|
||||
"Contacts".into(),
|
||||
None,
|
||||
Some(5),
|
||||
Vec::new(),
|
||||
Some(42),
|
||||
Some("seed Contacts 5".into()),
|
||||
))
|
||||
.expect("seed");
|
||||
};
|
||||
let (p1, db1, _d1) = open_project_db();
|
||||
@@ -1023,13 +1269,20 @@ fn seed_shortid_columns_are_reproducible_with_a_fixed_seed() {
|
||||
|
||||
let csv1 = read_csv(&p1, "Contacts").unwrap();
|
||||
let csv2 = read_csv(&p2, "Contacts").unwrap();
|
||||
assert_eq!(csv1, csv2, "shortid values must reproduce under a fixed --seed");
|
||||
assert_eq!(
|
||||
csv1, csv2,
|
||||
"shortid values must reproduce under a fixed --seed"
|
||||
);
|
||||
|
||||
// The shortid PK is populated with distinct 10-char base58 ids.
|
||||
let codes = nth_column_values(&csv1, 0);
|
||||
assert_eq!(codes.len(), 5);
|
||||
let distinct: std::collections::HashSet<&String> = codes.iter().collect();
|
||||
assert_eq!(distinct.len(), 5, "shortid PK values must be distinct: {codes:?}");
|
||||
assert_eq!(
|
||||
distinct.len(),
|
||||
5,
|
||||
"shortid PK values must be distinct: {codes:?}"
|
||||
);
|
||||
for code in &codes {
|
||||
assert_eq!(code.len(), 10, "shortid should be 10 chars: {code}");
|
||||
}
|
||||
@@ -1105,7 +1358,10 @@ fn seed_set_fixed_value_fills_every_row() {
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
let statuses = named_column_values(&csv, "status");
|
||||
assert_eq!(statuses.len(), 6);
|
||||
assert!(statuses.iter().all(|s| s == "active"), "every status pinned: {statuses:?}");
|
||||
assert!(
|
||||
statuses.iter().all(|s| s == "active"),
|
||||
"every status pinned: {statuses:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1113,7 +1369,12 @@ fn seed_set_pick_list_draws_only_from_the_list() {
|
||||
let (project, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 20 set role in ('admin', 'user') --seed 2").expect("seed");
|
||||
run_seed(
|
||||
&db,
|
||||
&rt,
|
||||
"seed Members 20 set role in ('admin', 'user') --seed 2",
|
||||
)
|
||||
.expect("seed");
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
let roles = named_column_values(&csv, "role");
|
||||
assert!(
|
||||
@@ -1131,7 +1392,10 @@ fn seed_set_as_generator_forces_the_shape() {
|
||||
run_seed(&db, &rt, "seed Members 5 set name as email --seed 3").expect("seed");
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
let names = named_column_values(&csv, "name");
|
||||
assert!(names.iter().all(|n| n.contains('@')), "name forced to email shape: {names:?}");
|
||||
assert!(
|
||||
names.iter().all(|n| n.contains('@')),
|
||||
"name forced to email shape: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1139,7 +1403,12 @@ fn seed_set_numeric_range_stays_within_bounds() {
|
||||
let (project, db, _d) = open_project_db();
|
||||
let rt = rt();
|
||||
create_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 30 set age between 30 and 40 --seed 4").expect("seed");
|
||||
run_seed(
|
||||
&db,
|
||||
&rt,
|
||||
"seed Members 30 set age between 30 and 40 --seed 4",
|
||||
)
|
||||
.expect("seed");
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
for a in named_column_values(&csv, "age") {
|
||||
let n: i64 = a.parse().unwrap_or_else(|_| panic!("age `{a}` not an int"));
|
||||
@@ -1190,7 +1459,10 @@ fn seed_incompatible_range_is_a_friendly_error() {
|
||||
// A numeric range on a text column (`name`) is rejected.
|
||||
let err = run_seed(&db, &rt, "seed Members 3 set name between 1 and 10").unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("between"), "range error should mention `between`: {msg}");
|
||||
assert!(
|
||||
msg.contains("between"),
|
||||
"range error should mention `between`: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1221,7 +1493,11 @@ fn seed_column_fill_updates_existing_rows_without_adding() {
|
||||
let before = data_row_count(&read_csv(&project, "Members").unwrap());
|
||||
assert_eq!(before, 5);
|
||||
|
||||
let res = run_seed(&db, &rt, "seed Members.status set status in ('x', 'y') --seed 2")
|
||||
let res = run_seed(
|
||||
&db,
|
||||
&rt,
|
||||
"seed Members.status set status in ('x', 'y') --seed 2",
|
||||
)
|
||||
.expect("column-fill");
|
||||
assert_eq!(res.produced, 5, "column-fill touches the 5 existing rows");
|
||||
let csv = read_csv(&project, "Members").unwrap();
|
||||
@@ -1240,7 +1516,10 @@ fn seed_column_fill_refuses_a_pk_target() {
|
||||
create_members(&db, &rt);
|
||||
run_seed(&db, &rt, "seed Members 3 --seed 1").expect("seed");
|
||||
let err = run_seed(&db, &rt, "seed Members.id").unwrap_err();
|
||||
assert!(format!("{err}").contains("primary key"), "PK target refused: {err}");
|
||||
assert!(
|
||||
format!("{err}").contains("primary key"),
|
||||
"PK target refused: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1282,7 +1561,10 @@ fn seed_column_fill_rejects_a_row_count() {
|
||||
Some("seed Members.status 5".into()),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("no row count"), "count refused: {err}");
|
||||
assert!(
|
||||
format!("{err}").contains("no row count"),
|
||||
"count refused: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1298,7 +1580,11 @@ fn seed_column_fill_fk_target_samples_the_parent() {
|
||||
assert_eq!(res.produced, 8);
|
||||
let csv = read_csv(&project, "Orders").unwrap();
|
||||
let user_ids = named_column_values(&csv, "user_id");
|
||||
assert!(user_ids.iter().all(|v| (1..=4).contains(&v.parse::<i64>().unwrap())));
|
||||
assert!(
|
||||
user_ids
|
||||
.iter()
|
||||
.all(|v| (1..=4).contains(&v.parse::<i64>().unwrap()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1310,14 +1596,11 @@ fn seed_fixed_override_on_unique_column_is_a_friendly_error() {
|
||||
let rt = rt();
|
||||
rt.block_on(db.create_table(
|
||||
"U".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Serial),
|
||||
{
|
||||
vec![ColumnSpec::new("id", Type::Serial), {
|
||||
let mut c = ColumnSpec::new("email", Type::Text);
|
||||
c.unique = true;
|
||||
c
|
||||
},
|
||||
],
|
||||
}],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
))
|
||||
@@ -1330,7 +1613,10 @@ fn seed_fixed_override_on_unique_column_is_a_friendly_error() {
|
||||
);
|
||||
// A short pick-list (< count) is likewise refused...
|
||||
let err2 = run_seed(&db, &rt, "seed U 5 set email in ('a@b.c', 'd@e.f')").unwrap_err();
|
||||
assert!(format!("{err2}").contains("distinct"), "short list refused: {err2}");
|
||||
assert!(
|
||||
format!("{err2}").contains("distinct"),
|
||||
"short list refused: {err2}"
|
||||
);
|
||||
// ...but a pick-list with enough distinct values succeeds.
|
||||
let ok = run_seed(
|
||||
&db,
|
||||
@@ -1354,14 +1640,11 @@ fn seed_column_fill_fixed_on_unique_column_is_a_friendly_error() {
|
||||
let rt = rt();
|
||||
rt.block_on(db.create_table(
|
||||
"U".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Serial),
|
||||
{
|
||||
vec![ColumnSpec::new("id", Type::Serial), {
|
||||
let mut c = ColumnSpec::new("email", Type::Text);
|
||||
c.unique = true;
|
||||
c
|
||||
},
|
||||
],
|
||||
}],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
))
|
||||
|
||||
+31
-15
@@ -17,7 +17,7 @@ use rdbms_playground::action::Action;
|
||||
use rdbms_playground::app::App;
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::{
|
||||
parse_command, ColumnSpec, Command, ReferentialAction, ShowListKind, Type,
|
||||
ColumnSpec, Command, ReferentialAction, ShowListKind, Type, parse_command,
|
||||
};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::mode::Mode;
|
||||
@@ -108,8 +108,7 @@ fn rt() -> tokio::runtime::Runtime {
|
||||
|
||||
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create tempdir");
|
||||
let project =
|
||||
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||
let persistence = Persistence::new(project.path().to_path_buf());
|
||||
let db = Database::open_with_persistence(project.db_path(), persistence)
|
||||
.expect("open db with persistence");
|
||||
@@ -195,8 +194,7 @@ fn show_relationships_lists_name_endpoints_and_nondefault_action() {
|
||||
// Name, both endpoints, and the non-default ON DELETE CASCADE
|
||||
// (ON UPDATE NO ACTION is the default and is omitted).
|
||||
assert_eq!(
|
||||
lines[1],
|
||||
" orders_customer: Customers.id → Orders.customer_id on delete cascade",
|
||||
lines[1], " orders_customer: Customers.id → Orders.customer_id on delete cascade",
|
||||
"relationship summary line: {lines:?}",
|
||||
);
|
||||
}
|
||||
@@ -222,7 +220,8 @@ fn show_lists_report_empty_collections_with_friendly_lines() {
|
||||
let rt = rt();
|
||||
// No schema seeded — every kind is empty.
|
||||
assert_eq!(
|
||||
rt.block_on(db.show_list(ShowListKind::Tables, None)).unwrap(),
|
||||
rt.block_on(db.show_list(ShowListKind::Tables, None))
|
||||
.unwrap(),
|
||||
vec!["No tables in this project yet.".to_string()],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -231,7 +230,8 @@ fn show_lists_report_empty_collections_with_friendly_lines() {
|
||||
vec!["No relationships in this project yet.".to_string()],
|
||||
);
|
||||
assert_eq!(
|
||||
rt.block_on(db.show_list(ShowListKind::Indexes, None)).unwrap(),
|
||||
rt.block_on(db.show_list(ShowListKind::Indexes, None))
|
||||
.unwrap(),
|
||||
vec!["No indexes in this project yet.".to_string()],
|
||||
);
|
||||
}
|
||||
@@ -246,7 +246,10 @@ fn show_one_relationship_renders_detail_block() {
|
||||
let rt = rt();
|
||||
rt.block_on(seed_schema(&db));
|
||||
let lines = rt
|
||||
.block_on(db.show_list(ShowListKind::Relationships, Some("orders_customer".to_string())))
|
||||
.block_on(db.show_list(
|
||||
ShowListKind::Relationships,
|
||||
Some("orders_customer".to_string()),
|
||||
))
|
||||
.expect("show relationship");
|
||||
assert_eq!(lines[0], "Relationship `orders_customer`:");
|
||||
assert_eq!(lines[1], " Customers.id → Orders.customer_id");
|
||||
@@ -262,7 +265,10 @@ fn show_one_index_renders_detail_block() {
|
||||
let rt = rt();
|
||||
rt.block_on(seed_schema(&db));
|
||||
let lines = rt
|
||||
.block_on(db.show_list(ShowListKind::Indexes, Some("idx_orders_customer".to_string())))
|
||||
.block_on(db.show_list(
|
||||
ShowListKind::Indexes,
|
||||
Some("idx_orders_customer".to_string()),
|
||||
))
|
||||
.expect("show index");
|
||||
assert_eq!(lines[0], "Index `idx_orders_customer` on Orders:");
|
||||
assert!(
|
||||
@@ -329,7 +335,10 @@ fn app_show_tables_dispatches_show_list_command() {
|
||||
}
|
||||
)
|
||||
});
|
||||
assert!(dispatched, "submit dispatches ShowList(Tables): {actions:?}");
|
||||
assert!(
|
||||
dispatched,
|
||||
"submit dispatches ShowList(Tables): {actions:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -337,7 +346,8 @@ fn app_renders_show_list_lines_as_system_output() {
|
||||
// Feed the success event directly so the test stays
|
||||
// self-contained (the worker round-trip is covered above).
|
||||
let mut app = App::new();
|
||||
app.output.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
app.output
|
||||
.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
"show tables",
|
||||
Mode::Simple,
|
||||
));
|
||||
@@ -403,7 +413,8 @@ fn app_renders_show_relationship_as_a_styled_diagram() {
|
||||
.expect("found");
|
||||
|
||||
let mut app = App::new();
|
||||
app.output.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
app.output
|
||||
.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
"show relationship orders_customer",
|
||||
Mode::Simple,
|
||||
));
|
||||
@@ -423,7 +434,10 @@ fn app_renders_show_relationship_as_a_styled_diagram() {
|
||||
// Both tables, box-drawing, the connector arrow, the actions line.
|
||||
assert!(text.contains("Orders"), "child box: {text}");
|
||||
assert!(text.contains("Customers"), "parent box: {text}");
|
||||
assert!(text.contains('┌') && text.contains('│'), "box drawing: {text}");
|
||||
assert!(
|
||||
text.contains('┌') && text.contains('│'),
|
||||
"box drawing: {text}"
|
||||
);
|
||||
assert!(text.contains('▶'), "connector arrow: {text}");
|
||||
assert!(text.contains("on delete cascade"), "actions: {text}");
|
||||
// The diagram lines are styled (per-span runs), not plain system.
|
||||
@@ -436,7 +450,8 @@ fn app_renders_show_relationship_as_a_styled_diagram() {
|
||||
#[test]
|
||||
fn app_show_relationship_not_found_shows_friendly_line() {
|
||||
let mut app = App::new();
|
||||
app.output.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
app.output
|
||||
.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
"show relationship nope",
|
||||
Mode::Simple,
|
||||
));
|
||||
@@ -466,7 +481,8 @@ fn app_show_table_renders_relationships_as_compact_diagrams() {
|
||||
.expect("describe Orders");
|
||||
|
||||
let mut app = App::new();
|
||||
app.output.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
app.output
|
||||
.push_back(rdbms_playground::app::OutputLine::echo(
|
||||
"show table Orders",
|
||||
Mode::Simple,
|
||||
));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user