Compare commits
117
Commits
v0.1.0
..
44b495dfed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44b495dfed | ||
|
|
78272e2fd0 | ||
|
|
e1d7419a72 | ||
|
|
6e28e58e45 | ||
|
|
5c51a00e72 | ||
|
|
ec7a8ff3be | ||
|
|
f06ee3b460 | ||
|
|
279ef98ab8 | ||
|
|
4570c4e1ea | ||
|
|
3585cca5ea | ||
|
|
63d11402ae | ||
|
|
8ccecc4245 | ||
|
|
b5c848efcb | ||
|
|
6d4364666a | ||
|
|
48fc5e8202 | ||
|
|
9812463620 | ||
|
|
208da81108 | ||
|
|
e845a6ee72 | ||
|
|
c9d6660ba6 | ||
|
|
6b4c4dcea4 | ||
|
|
1a2002dbf6 | ||
|
|
64818c08f6 | ||
|
|
3ad4affef2 | ||
|
|
e88fa79f09 | ||
|
|
07575da983 | ||
|
|
010dbf8e9e | ||
|
|
35ca108fa1 | ||
|
|
1ffe11cb1f | ||
|
|
c1f24599da | ||
|
|
a8ebbfba26 | ||
|
|
f06cbbc788 | ||
|
|
f2b4ed00f4 | ||
|
|
88204f25c5 | ||
|
|
fd63de3441 | ||
|
|
65eab71439 | ||
|
|
c86df95a0f | ||
|
|
fb536b4245 | ||
|
|
1237aa59c1 | ||
|
|
ba0872dbb9 | ||
|
|
56e3456cfc | ||
|
|
0208c67e59 | ||
|
|
6bb2288470 | ||
|
|
6d54c1e96c | ||
|
|
c0531aa048 | ||
|
|
42b40bc099 | ||
|
|
cabc8131a9 | ||
|
|
8ebe213b5d | ||
|
|
d3af1c413a | ||
|
|
3c87dbb391 | ||
|
|
bd5be5ecc7 | ||
|
|
88830ed06a | ||
|
|
ec3c7c304c | ||
|
|
41b7e9a049 | ||
|
|
e9606b5f6d | ||
|
|
ef99e6c676 | ||
|
|
c30a6114b9 | ||
|
|
fe9d58e037 | ||
|
|
628b250db6 | ||
|
|
784373a254 | ||
|
|
dff78412dd | ||
|
|
028d32420d | ||
|
|
c84a640259 | ||
|
|
407408ec29 | ||
|
|
4016c3e5cd | ||
|
|
1feb803aab | ||
|
|
93a40970c3 | ||
|
|
96b9581089 | ||
|
|
b60c0bb0ec | ||
|
|
c2baf6923b | ||
|
|
1660a6a17c | ||
|
|
ea38e7a151 | ||
|
|
5a37437055 | ||
|
|
3fe62af886 | ||
|
|
b4441507e2 | ||
|
|
77c55fa669 | ||
|
|
4691d7950a | ||
|
|
069f9277d1 | ||
|
|
09b64cbfb7 | ||
|
|
abd3739168 | ||
|
|
a72d53de51 | ||
|
|
6777216e37 | ||
|
|
13c9c1bcd9 | ||
|
|
946dd88db6 | ||
|
|
ad43cce945 | ||
|
|
7099bd3cde | ||
|
|
5908891d6b | ||
|
|
6778c338d4 | ||
|
|
823b413ca3 | ||
|
|
a0dd202f67 | ||
|
|
595386e370 | ||
|
|
51a29e5069 | ||
|
|
e782a280cc | ||
|
|
927e6b2d50 | ||
|
|
52860c3267 | ||
|
|
ce153bde4c | ||
|
|
302329d5b2 | ||
|
|
65a48fa5ae | ||
|
|
bb7887ea82 | ||
|
|
a8f84c9d17 | ||
|
|
1f82fb2c79 | ||
|
|
44f91724b6 | ||
|
|
c904dbb68b | ||
|
|
fbf449f9e0 | ||
|
|
c0cc92a741 | ||
|
|
10655e46de | ||
|
|
619c200ea1 | ||
|
|
dfb5f0b1b1 | ||
|
|
39e97ac3f9 | ||
|
|
936d9254c0 | ||
|
|
44390e765d | ||
|
|
995c0ba8eb | ||
|
|
c72c624daa | ||
|
|
9e774b2dfa | ||
|
|
40de389bcb | ||
|
|
0fcb7b1105 | ||
|
|
cea99e8b70 | ||
|
|
1fad29c0f9 |
@@ -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
|
||||||
+47
-10
@@ -2,28 +2,45 @@
|
|||||||
# build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps
|
# build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps
|
||||||
# just enter the flake devShell and run cargo.
|
# just enter the flake devShell and run cargo.
|
||||||
#
|
#
|
||||||
# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-ci-002: the tree
|
# Gate = fmt + clippy + test. The fmt gate (`cargo fmt --check`, stock defaults)
|
||||||
# isn't clean under stock rustfmt; revisit on main). The release job (static
|
# was enabled once the tree was reformatted on main (ADR-ci-002 Amendment 1 /
|
||||||
# binary for D2) and the platform matrix layer on later, step by step.
|
# issue #35). The release job (static binary for D2) and the platform matrix
|
||||||
|
# layer on later, step by step.
|
||||||
|
#
|
||||||
|
# A separate, lightweight `manifests` job logic-tests the package-manifest
|
||||||
|
# render scripts (Scoop/Homebrew) used by publish.yaml — bash + node only, no
|
||||||
|
# toolchain — so a render regression surfaces on the breaking push rather than
|
||||||
|
# weeks later at the next manual publish dispatch (ADR-0056 Amendment 2).
|
||||||
name: ci
|
name: ci
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
# Branch pushes only — a tag push hits the same commit the branch push
|
# Only `main` (the post-merge gate + canonical branch). Feature branches
|
||||||
# already gated, so `branches: ['**']` drops the redundant tag-triggered
|
# are gated via `pull_request` instead. Running both on a push to a PR'd
|
||||||
# run (the release workflow owns tags). Pushing commits + a tag together
|
# branch was pure duplication on Gitea: unlike GitHub it has no merge-
|
||||||
# still gates the commits via the branch push.
|
# preview ref — its `pull_request` checks out `refs/pull/N/head`, the same
|
||||||
branches: ['**']
|
# commit a branch push would, so the two runs were byte-identical
|
||||||
# Skip the gate for docs-only changes — markdown can't affect clippy/test.
|
# (docs.gitea.com/usage/actions/faq). Gating on `pull_request` is also
|
||||||
# A push touching code *and* docs still runs (not all files are ignored).
|
# forward-compatible: if Gitea ever adds the merge ref, these runs upgrade
|
||||||
|
# to testing the merged result for free. Tags stay unmatched (release.yaml
|
||||||
|
# owns them).
|
||||||
|
branches: [main]
|
||||||
|
# Skip the gate for changes that can't affect clippy/test — docs, markdown,
|
||||||
|
# and the website subproject (it has its own workflow, website.yaml, that
|
||||||
|
# builds + publishes it). A push touching crate code *and* these still runs
|
||||||
|
# (paths-ignore only skips when *all* changed files match).
|
||||||
# Note: flake/toolchain changes are NOT ignored — they can shift the
|
# Note: flake/toolchain changes are NOT ignored — they can shift the
|
||||||
# toolchain and thus lint/test outcomes.
|
# toolchain and thus lint/test outcomes.
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
|
- 'website/**'
|
||||||
|
- '.gitea/workflows/website.yaml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
|
- 'website/**'
|
||||||
|
- '.gitea/workflows/website.yaml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
gate:
|
gate:
|
||||||
@@ -33,7 +50,27 @@ jobs:
|
|||||||
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: fmt (check, stock defaults)
|
||||||
|
run: nix develop -c cargo fmt --check
|
||||||
- name: clippy (warnings denied)
|
- name: clippy (warnings denied)
|
||||||
run: nix develop -c cargo clippy --all-targets -- -D warnings
|
run: nix develop -c cargo clippy --all-targets -- -D warnings
|
||||||
- name: test
|
- name: test
|
||||||
run: nix develop -c cargo test --no-fail-fast
|
run: nix develop -c cargo test --no-fail-fast
|
||||||
|
|
||||||
|
# Logic test for the package-manifest render scripts. Renders with DUMMY
|
||||||
|
# inputs and validates the output — it never publishes or touches the lazyeval
|
||||||
|
# repos (that is publish.yaml's manual job). Runs on the same image but skips
|
||||||
|
# nix: it needs only bash + node, both in the base image.
|
||||||
|
#
|
||||||
|
# NOTE: the CI image has no ruby, so the script's `ruby -c` formula syntax
|
||||||
|
# check is skipped here (it degrades gracefully); the Scoop JSON is still
|
||||||
|
# validated with node and both manifests' fields are asserted. Full formula
|
||||||
|
# syntax is checked dev-side (ruby present) on every pre-commit local run.
|
||||||
|
manifests:
|
||||||
|
runs-on: ci-public
|
||||||
|
container:
|
||||||
|
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: render-script tests (Scoop + Homebrew)
|
||||||
|
run: bash scripts/test-package-renders.sh
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# Manual publication workflow (workflow_dispatch) — the outward, irreversible
|
||||||
|
# release steps a human triggers AFTER the automated `release.yaml` build has
|
||||||
|
# produced downloadable assets (and they've been eyeballed as good).
|
||||||
|
#
|
||||||
|
# Why manual + separate from release.yaml:
|
||||||
|
# * Publishing to a public registry is irreversible (crates.io versions can
|
||||||
|
# only be *yanked*, never deleted) — a human pulls this lever, and the
|
||||||
|
# registry token never sits on every tag push.
|
||||||
|
# * Our release is split (Linux/Windows on the tag, macOS dispatched), so a
|
||||||
|
# human is the natural "all assets are up — go" gate. crates.io publish
|
||||||
|
# reads SOURCE so it doesn't strictly need the release, but binstall's
|
||||||
|
# metadata points at the release assets — hence run this once builds exist.
|
||||||
|
#
|
||||||
|
# Structure: each registry is its OWN job with NO inter-job `needs`, so jobs run
|
||||||
|
# independently and one failing (or a newly-added one) never breaks another.
|
||||||
|
# Every job is IDEMPOTENT — re-dispatching when a target is already published is
|
||||||
|
# a clean no-op. Add Scoop / Homebrew / winget as sibling jobs here later.
|
||||||
|
name: publish
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag to publish (e.g. v0.2.0)'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
crates-io:
|
||||||
|
runs-on: ci-public
|
||||||
|
container:
|
||||||
|
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: publish to crates.io (idempotent)
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
# A crate-scoped, publish-update crates.io token, stored as a Gitea
|
||||||
|
# Actions secret. `cargo publish` reads CARGO_REGISTRY_TOKEN from env.
|
||||||
|
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Source of truth = the [package] version at the checked-out tag
|
||||||
|
# (toolchain-free read; same approach as release.yaml's guard, which
|
||||||
|
# avoids the flake devShell's stdout banner corrupting a parse).
|
||||||
|
VER=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/^version = "(.*)"/\1/')
|
||||||
|
[ -n "$VER" ] || { echo "ERROR: could not read version from Cargo.toml" >&2; exit 1; }
|
||||||
|
if [ "$TAG" != "v$VER" ]; then
|
||||||
|
echo "ERROR: dispatch tag '$TAG' != 'v$VER' (Cargo.toml at that tag)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Idempotency: if this version is already on crates.io, no-op.
|
||||||
|
# (crates.io requires a descriptive User-Agent per its data policy;
|
||||||
|
# without one the API returns 403.) Only an explicit 200 means
|
||||||
|
# "already there" — anything else proceeds, and `cargo publish` is the
|
||||||
|
# final backstop (it refuses to overwrite an existing version).
|
||||||
|
UA="rdbms-playground-release-ci (oliver@sturmnet.org)"
|
||||||
|
code=$(curl -sS -o /dev/null -w '%{http_code}' -A "$UA" \
|
||||||
|
"https://crates.io/api/v1/crates/rdbms-playground/$VER" || echo 000)
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
echo "rdbms-playground $VER is already on crates.io — nothing to do."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "crates.io returned HTTP $code for $VER (not 200) — proceeding to publish."
|
||||||
|
|
||||||
|
echo "publishing rdbms-playground $VER to crates.io ..."
|
||||||
|
nix develop -c cargo publish --locked
|
||||||
|
echo "published rdbms-playground $VER to crates.io."
|
||||||
|
|
||||||
|
# Update the lazyeval Scoop bucket (Windows). Renders the manifest from the
|
||||||
|
# release's .sha256 sidecars and commits it to lazyeval/scoop-bucket. Pushes
|
||||||
|
# with the lazyeval-ci bot token (LAZYEVAL_PKG_TOKEN), which is scoped — via
|
||||||
|
# the bot's org-team membership — to the lazyeval package repos only, so a
|
||||||
|
# leak cannot touch oli/rdbms-playground.
|
||||||
|
scoop-bucket:
|
||||||
|
runs-on: ci-public
|
||||||
|
container:
|
||||||
|
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4 # default ref (main) — current render script
|
||||||
|
|
||||||
|
- name: update the lazyeval Scoop bucket (idempotent)
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
# Passed via env, never inlined into the script, so the value stays
|
||||||
|
# masked in logs; it only materialises in the clone URL at runtime.
|
||||||
|
PKG_TOKEN: ${{ secrets.LAZYEVAL_PKG_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VER="${TAG#v}"
|
||||||
|
echo "scoop: targeting rdbms-playground $VER ($TAG)"
|
||||||
|
|
||||||
|
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG"
|
||||||
|
fetch_hash() {
|
||||||
|
local asset="$1" line
|
||||||
|
echo "scoop: fetching $asset.sha256" >&2
|
||||||
|
line=$(curl -fsSL "$base/$asset.sha256") \
|
||||||
|
|| { echo "ERROR: cannot fetch $asset.sha256 — is $TAG released with assets?" >&2; exit 1; }
|
||||||
|
# First whitespace-delimited field is the hash. `read` is a bash
|
||||||
|
# builtin (no awk, which the slim CI image may lack).
|
||||||
|
local hash _
|
||||||
|
read -r hash _ <<<"$line"
|
||||||
|
printf '%s' "$hash"
|
||||||
|
}
|
||||||
|
h_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-pc-windows-gnu.exe")
|
||||||
|
h_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-pc-windows-gnullvm.exe")
|
||||||
|
|
||||||
|
echo "scoop: rendering manifest"
|
||||||
|
bash scripts/render-scoop-manifest.sh "$VER" "$h_x64" "$h_arm" > /tmp/rdbms-playground.json
|
||||||
|
node -e 'JSON.parse(require("fs").readFileSync("/tmp/rdbms-playground.json","utf8"))' \
|
||||||
|
|| { echo "ERROR: rendered Scoop manifest is not valid JSON" >&2; exit 1; }
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
echo "scoop: cloning lazyeval/scoop-bucket"
|
||||||
|
git clone --depth 1 "https://lazyeval-ci:${PKG_TOKEN}@git.lazyeval.net/lazyeval/scoop-bucket.git" "$work"
|
||||||
|
cp /tmp/rdbms-playground.json "$work/rdbms-playground.json"
|
||||||
|
|
||||||
|
cd "$work"
|
||||||
|
git config user.name "lazyeval-ci"
|
||||||
|
git config user.email "ci@lazyeval.net"
|
||||||
|
git add rdbms-playground.json
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "scoop: manifest already at $VER — nothing to commit."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "rdbms-playground $VER"
|
||||||
|
# Push to main explicitly: a freshly-created (empty) repo clone may put
|
||||||
|
# the first commit on a differently-named local branch. Assumes the
|
||||||
|
# bucket/tap default branch is `main` (Gitea's default for new repos).
|
||||||
|
git push origin HEAD:main
|
||||||
|
echo "scoop: bucket updated to rdbms-playground $VER."
|
||||||
|
|
||||||
|
# Update the lazyeval Homebrew tap (macOS + Linux). Same shape as scoop-bucket;
|
||||||
|
# writes Formula/rdbms-playground.rb into lazyeval/homebrew-tap.
|
||||||
|
homebrew-tap:
|
||||||
|
runs-on: ci-public
|
||||||
|
container:
|
||||||
|
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: update the lazyeval Homebrew tap (idempotent)
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
PKG_TOKEN: ${{ secrets.LAZYEVAL_PKG_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VER="${TAG#v}"
|
||||||
|
echo "homebrew: targeting rdbms-playground $VER ($TAG)"
|
||||||
|
|
||||||
|
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG"
|
||||||
|
fetch_hash() {
|
||||||
|
local asset="$1" line
|
||||||
|
echo "homebrew: fetching $asset.sha256" >&2
|
||||||
|
line=$(curl -fsSL "$base/$asset.sha256") \
|
||||||
|
|| { echo "ERROR: cannot fetch $asset.sha256 — is $TAG released with assets?" >&2; exit 1; }
|
||||||
|
# First whitespace-delimited field is the hash. `read` is a bash
|
||||||
|
# builtin (no awk, which the slim CI image may lack).
|
||||||
|
local hash _
|
||||||
|
read -r hash _ <<<"$line"
|
||||||
|
printf '%s' "$hash"
|
||||||
|
}
|
||||||
|
mac_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-apple-darwin")
|
||||||
|
mac_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-apple-darwin")
|
||||||
|
lin_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-unknown-linux-musl")
|
||||||
|
lin_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-unknown-linux-musl")
|
||||||
|
|
||||||
|
echo "homebrew: rendering formula"
|
||||||
|
bash scripts/render-homebrew-formula.sh "$VER" "$mac_arm" "$mac_x64" "$lin_arm" "$lin_x64" \
|
||||||
|
> /tmp/rdbms-playground.rb
|
||||||
|
grep -q '^class RdbmsPlayground < Formula$' /tmp/rdbms-playground.rb \
|
||||||
|
|| { echo "ERROR: rendered formula looks malformed" >&2; exit 1; }
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
echo "homebrew: cloning lazyeval/homebrew-tap"
|
||||||
|
git clone --depth 1 "https://lazyeval-ci:${PKG_TOKEN}@git.lazyeval.net/lazyeval/homebrew-tap.git" "$work"
|
||||||
|
mkdir -p "$work/Formula"
|
||||||
|
cp /tmp/rdbms-playground.rb "$work/Formula/rdbms-playground.rb"
|
||||||
|
|
||||||
|
cd "$work"
|
||||||
|
git config user.name "lazyeval-ci"
|
||||||
|
git config user.email "ci@lazyeval.net"
|
||||||
|
git add Formula/rdbms-playground.rb
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "homebrew: formula already at $VER — nothing to commit."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "rdbms-playground $VER"
|
||||||
|
# Push to main explicitly: a freshly-created (empty) repo clone may put
|
||||||
|
# the first commit on a differently-named local branch. Assumes the
|
||||||
|
# bucket/tap default branch is `main` (Gitea's default for new repos).
|
||||||
|
git push origin HEAD:main
|
||||||
|
echo "homebrew: tap updated to rdbms-playground $VER."
|
||||||
|
|
||||||
|
# Update the winget package (Windows) by opening a PR to microsoft/winget-pkgs
|
||||||
|
# with komac. Unlike scoop-bucket/homebrew-tap (which push to OUR repos and are
|
||||||
|
# live at once), winget is a PR into Microsoft's central, human-gated catalog —
|
||||||
|
# asynchronous, and re-submitting the same version would open a DUPLICATE PR. So
|
||||||
|
# this job guards on both already-merged versions AND already-open PRs before
|
||||||
|
# submitting, which keeps a repeated `publish` dispatch safe.
|
||||||
|
#
|
||||||
|
# Auth: komac needs a CLASSIC GitHub PAT with `public_repo` (fine-grained tokens
|
||||||
|
# cannot open the cross-fork PR — komac #310). It is held on a dedicated GitHub
|
||||||
|
# bot account (a leak can't reach other repos — same reasoning as lazyeval-ci)
|
||||||
|
# and referenced ONLY in this job's env (job-level secret scoping keeps it out
|
||||||
|
# of the other publish jobs).
|
||||||
|
#
|
||||||
|
# PREREQUISITE: the package `LazyEvaluation.RdbmsPlayground` must already exist
|
||||||
|
# in winget-pkgs via a one-time `komac new` (interactive — run manually once;
|
||||||
|
# see ADR-0056 Amendment 4). This job only does the per-release `komac update`.
|
||||||
|
winget:
|
||||||
|
runs-on: ci-public
|
||||||
|
container:
|
||||||
|
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||||
|
steps:
|
||||||
|
- name: submit the winget update PR (idempotent)
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
# Classic public_repo PAT for the winget bot account. komac reads it
|
||||||
|
# from GITHUB_TOKEN; the API guards below read it via a curl config file
|
||||||
|
# so the token never appears in a command line / process list.
|
||||||
|
GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VER="${TAG#v}"
|
||||||
|
PKG="LazyEvaluation.RdbmsPlayground"
|
||||||
|
echo "winget: targeting $PKG $VER ($TAG)"
|
||||||
|
|
||||||
|
# Auth header in a 0600 curl config (keeps the token out of argv/logs).
|
||||||
|
umask 077
|
||||||
|
printf 'header = "Authorization: Bearer %s"\nheader = "Accept: application/vnd.github+json"\n' \
|
||||||
|
"$GITHUB_TOKEN" > /tmp/gh-curlrc
|
||||||
|
api="https://api.github.com"
|
||||||
|
|
||||||
|
# Guard 1 — already merged into winget-pkgs?
|
||||||
|
# (manifests/<first-letter>/<Publisher>/<Package>/<version>)
|
||||||
|
merged=$(curl -sS -o /dev/null -w '%{http_code}' --config /tmp/gh-curlrc \
|
||||||
|
"$api/repos/microsoft/winget-pkgs/contents/manifests/l/LazyEvaluation/RdbmsPlayground/$VER" || echo 000)
|
||||||
|
if [ "$merged" = "200" ]; then
|
||||||
|
echo "winget: $PKG $VER already in winget-pkgs — nothing to do."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Guard 2 — an open PR for this exact id+version already? (avoid a dup)
|
||||||
|
# curl -G --data-urlencode does the URL-encoding (no jq in the image).
|
||||||
|
curl -sS -G --config /tmp/gh-curlrc \
|
||||||
|
--data-urlencode "q=repo:microsoft/winget-pkgs type:pr state:open in:title \"$PKG\" \"$VER\"" \
|
||||||
|
"$api/search/issues" -o /tmp/winget-search.json
|
||||||
|
open=$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync("/tmp/winget-search.json","utf8")).total_count||0))')
|
||||||
|
if [ "$open" != "0" ]; then
|
||||||
|
echo "winget: an open PR for $PKG $VER already exists — skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install pinned komac (prebuilt glibc binary; the CI image has no
|
||||||
|
# cargo/komac). Pinned for reproducibility — bump deliberately.
|
||||||
|
KOMAC_VER=2.16.0
|
||||||
|
curl -fsSL -o /tmp/komac.tgz \
|
||||||
|
"https://github.com/russellbanks/Komac/releases/download/v$KOMAC_VER/komac-$KOMAC_VER-x86_64-unknown-linux-gnu.tar.gz"
|
||||||
|
tar -xzf /tmp/komac.tgz -C /tmp
|
||||||
|
komac_bin=$(find /tmp -maxdepth 2 -type f -name komac | head -1)
|
||||||
|
[ -n "$komac_bin" ] || { echo "ERROR: komac binary not found after extract" >&2; exit 1; }
|
||||||
|
|
||||||
|
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG"
|
||||||
|
echo "winget: submitting update PR via komac $KOMAC_VER"
|
||||||
|
# NB: confirm flags against `komac update --help` on first run — komac
|
||||||
|
# evolves; --version/--urls/--submit are the stable core. komac infers
|
||||||
|
# architecture + the `portable` installer type from the binaries.
|
||||||
|
"$komac_bin" update "$PKG" \
|
||||||
|
--version "$VER" \
|
||||||
|
--urls "$base/rdbms-playground-$TAG-x86_64-pc-windows-gnu.exe" \
|
||||||
|
"$base/rdbms-playground-$TAG-aarch64-pc-windows-gnullvm.exe" \
|
||||||
|
--submit
|
||||||
|
echo "winget: update PR submitted for $PKG $VER (Microsoft review is async)."
|
||||||
|
|
||||||
|
# No `needs:` between jobs — each is independent and idempotent, so one failing
|
||||||
|
# or being added never breaks another.
|
||||||
@@ -84,12 +84,21 @@ jobs:
|
|||||||
# The runner wipes the workspace each run, so cargo target/ never
|
# The runner wipes the workspace each run, so cargo target/ never
|
||||||
# accumulates. Bound the persistent nix store by generation: record the
|
# accumulates. Bound the persistent nix store by generation: record the
|
||||||
# current devShell as a generation of a persistent profile (in $HOME),
|
# current devShell as a generation of a persistent profile (in $HOME),
|
||||||
# keep the 2 newest, reclaim what older ones referenced.
|
# keep the 2 newest, reclaim what older ones referenced — so the
|
||||||
|
# toolchain stays *warm* across runs and only stale generations are GC'd.
|
||||||
|
#
|
||||||
|
# The profile's parent dir MUST exist first, or `nix develop --profile`
|
||||||
|
# errors ("cannot read directory …") and the profile/gc-root is never
|
||||||
|
# created — which made `nix-collect-garbage` delete the whole toolchain
|
||||||
|
# closure every run (re-downloaded ~3.8 GiB each time; the retention was
|
||||||
|
# silently broken by the swallowed `|| true`). No `|| true` on the
|
||||||
|
# profile realization now: a future breakage should fail loudly.
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
echo "--- disk before ---"; df -h / | tail -1
|
echo "--- disk before ---"; df -h / | tail -1
|
||||||
P="$HOME/.cache/rdbms-ci/toolchain"
|
P="$HOME/.cache/rdbms-ci/toolchain"
|
||||||
nix develop --profile "$P" -c true || true
|
mkdir -p "$(dirname "$P")"
|
||||||
|
nix develop --profile "$P" -c true
|
||||||
nix-env -p "$P" --delete-generations +2 || true
|
nix-env -p "$P" --delete-generations +2 || true
|
||||||
nix-collect-garbage || true
|
nix-collect-garbage || true
|
||||||
echo "--- disk after ---"; df -h / | tail -1
|
echo "--- disk after ---"; df -h / | tail -1
|
||||||
|
|||||||
@@ -5,11 +5,15 @@
|
|||||||
# Matrix (D1, cross-built from Linux x86_64 via cargo-zigbuild):
|
# Matrix (D1, cross-built from Linux x86_64 via cargo-zigbuild):
|
||||||
# x86_64-unknown-linux-musl aarch64-unknown-linux-musl (static, D2)
|
# x86_64-unknown-linux-musl aarch64-unknown-linux-musl (static, D2)
|
||||||
# x86_64-pc-windows-gnu aarch64-pc-windows-gnullvm (standalone .exe)
|
# 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).
|
# The two macOS targets are built separately by the dispatched
|
||||||
# D3 package-manager manifests layer on later.
|
# 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
|
# 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
|
name: release
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -23,6 +27,27 @@ jobs:
|
|||||||
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: test
|
||||||
run: nix develop -c cargo test --no-fail-fast
|
run: nix develop -c cargo test --no-fail-fast
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Build the docs/marketing website and deploy it to Cloudflare Pages.
|
||||||
|
#
|
||||||
|
# One Pages project, two branches (no second project, no sub-folders — Pages
|
||||||
|
# maps a branch to a *subdomain alias*, not a path):
|
||||||
|
# main → production (the project's production branch → relplay.org)
|
||||||
|
# website → preview (alias `website.<project>.pages.dev`; a custom
|
||||||
|
# `staging.relplay.org` can be attached to it)
|
||||||
|
# wrangler treats `--branch=<production-branch>` as a production deploy and any
|
||||||
|
# other branch as a preview, so a single workflow covers both — the Pages
|
||||||
|
# project's production branch MUST be set to `main`.
|
||||||
|
#
|
||||||
|
# Pure-Node build: the `.cast` recordings are committed, so no cargo/Rust is
|
||||||
|
# needed here. Runs on the bare `ci-public` runner (node already present; pnpm
|
||||||
|
# via corepack, pinned by package.json's `packageManager`). No job container —
|
||||||
|
# unlike the Rust gate, this needs none.
|
||||||
|
#
|
||||||
|
# Required Actions secrets (set once in the repo/org settings):
|
||||||
|
# CLOUDFLARE_API_TOKEN — token with "Cloudflare Pages: Edit" on the account
|
||||||
|
# CLOUDFLARE_ACCOUNT_ID — the account id that owns the Pages project
|
||||||
|
name: website
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, website]
|
||||||
|
# Only when the site (or this workflow) actually changes — crate-only
|
||||||
|
# pushes don't redeploy the site.
|
||||||
|
paths:
|
||||||
|
- 'website/**'
|
||||||
|
- '.gitea/workflows/website.yaml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ci-public
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: website
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: preflight — toolchain present
|
||||||
|
run: |
|
||||||
|
node --version
|
||||||
|
corepack --version
|
||||||
|
|
||||||
|
- name: enable pnpm (pinned by packageManager)
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: install
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: deploy to Cloudflare Pages
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
BRANCH: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# `dist` is relative to website/ (the working-directory). The branch
|
||||||
|
# name decides production (main) vs preview (anything else).
|
||||||
|
npx --yes wrangler@4 pages deploy dist \
|
||||||
|
--project-name=relplay \
|
||||||
|
--branch="$BRANCH"
|
||||||
@@ -12,7 +12,13 @@
|
|||||||
*.snap.new
|
*.snap.new
|
||||||
*.pending-snap
|
*.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
|
# Editor / OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
# Astro/template-seeded editor configs we don't track (e.g. website/.vscode)
|
||||||
|
.vscode/
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable, user-facing changes to RDBMS Playground are documented here.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Pressing **F1** while the cursor sits inside a clause now adds a short
|
||||||
|
"About this clause" explanation beneath the command hint — covering
|
||||||
|
referential actions (`on delete`/`on update`), one-to-many and
|
||||||
|
many-to-many relationships, primary keys, unique and check constraints,
|
||||||
|
and foreign keys. The example shown matches whichever mode you're in.
|
||||||
|
- `help` now covers the advanced-mode SQL commands: `help select`, `help with`,
|
||||||
|
and the SQL forms of `insert` / `update` / `delete` / `explain` show their own
|
||||||
|
syntax, and the full command list is grouped into "Simple-mode commands" and
|
||||||
|
"Advanced-mode (SQL) commands" sections.
|
||||||
|
- Install via **Scoop**, **Homebrew**, and **winget** in addition to the
|
||||||
|
existing channels.
|
||||||
|
- Tier-4 end-to-end test suite that exercises the real application in a
|
||||||
|
terminal (cold launch, save/reopen, export/import, undo) — guards against
|
||||||
|
regressions the unit and render tests can't see.
|
||||||
|
- Automated colour-accessibility checks: every theme now has its
|
||||||
|
foreground/background contrast and its syntax-colour distinctness verified
|
||||||
|
on each build.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- The `blob` column type. It could be declared but never given a value
|
||||||
|
(there was no way to type a binary value in either mode), so it was a
|
||||||
|
dead end with nothing to teach. The vocabulary is now nine types.
|
||||||
|
Existing projects that used a `blob` column still open — the column is
|
||||||
|
converted to `text` on first load (a backup of the original project file
|
||||||
|
is kept alongside it).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Pasting or scripting several commands at once no longer occasionally
|
||||||
|
rejects a valid simple-mode `insert` — submitted right after adding a
|
||||||
|
column — as though it were advanced-mode SQL.
|
||||||
|
- **Light theme:** string-literal and flag colours in syntax highlighting
|
||||||
|
were below the WCAG-AA contrast bar; both are now legible. Two dark-theme
|
||||||
|
token colours that were hard to tell apart have been separated.
|
||||||
|
- Windows `install.ps1` now works on the in-box Windows PowerShell 5.1 and
|
||||||
|
updates `PATH` for the current session.
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Version surfaces:** a `--version` / `-V` command-line flag and an in-app
|
||||||
|
`version` command, both reporting the build's exact version.
|
||||||
|
- **Installation options:** publish to crates.io (`cargo install
|
||||||
|
rdbms-playground`), `cargo binstall` support, a one-line `curl | sh`
|
||||||
|
installer, and a Windows `install.ps1` — so you no longer have to hand-pick
|
||||||
|
a release asset.
|
||||||
|
- **Documentation & landing site** at <https://relplay.org> — the canonical
|
||||||
|
user guide plus screencast demos.
|
||||||
|
- A demonstration-mode key alias (Ctrl-G acting as F1) so screencasts can
|
||||||
|
show the in-app help/hint key.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Corrected several errors in the contextual-hint help text.
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-06-15
|
||||||
|
|
||||||
|
First public release: the cross-platform terminal sandbox for learning
|
||||||
|
relational-database concepts — tables, keys, relationships, indexes, queries
|
||||||
|
and query plans — in a guided simple mode or a full advanced (SQL) mode, with
|
||||||
|
projects, undo, and export/import.
|
||||||
|
|
||||||
|
[Unreleased]: https://git.lazyeval.net/oli/rdbms-playground/compare/v0.2.0...HEAD
|
||||||
|
[0.2.0]: https://git.lazyeval.net/oli/rdbms-playground/compare/v0.1.0...v0.2.0
|
||||||
|
[0.1.0]: https://git.lazyeval.net/oli/rdbms-playground/releases/tag/v0.1.0
|
||||||
@@ -37,9 +37,9 @@ Current decisions at a glance (each backed by an ADR):
|
|||||||
simple to advanced (ADR-0003). No other sigils.
|
simple to advanced (ADR-0003). No other sigils.
|
||||||
- **Project format:** `project.yaml` + `data/<table>.csv` +
|
- **Project format:** `project.yaml` + `data/<table>.csv` +
|
||||||
`history.log`; `playground.db` is a derived artifact (ADR-0004,
|
`history.log`; `playground.db` is a derived artifact (ADR-0004,
|
||||||
amended by ADR-0015). Implemented through Iteration 4 +
|
amended by ADR-0015). Fully implemented (ADR-0015 Iterations
|
||||||
cleanup; export/import (Iter 5) and migration framework /
|
1–6): export/import, `--resume`, persistent input history, and
|
||||||
--resume / persistent input history (Iter 6) pending.
|
the migration framework scaffold are all done.
|
||||||
- **Project storage runtime:** every command persists through to
|
- **Project storage runtime:** every command persists through to
|
||||||
db + yaml + csv + history.log in one execution context, gated
|
db + yaml + csv + history.log in one execution context, gated
|
||||||
by the combined db persistence logic; commit-db-last ordering
|
by the combined db persistence logic; commit-db-last ordering
|
||||||
@@ -58,8 +58,8 @@ Current decisions at a glance (each backed by an ADR):
|
|||||||
`[temp]`-marker / contents-allowlist guards. Anything
|
`[temp]`-marker / contents-allowlist guards. Anything
|
||||||
unexpected → refuse, never delete the wrong thing.
|
unexpected → refuse, never delete the wrong thing.
|
||||||
- **Types:** `text`, `int`, `real`, `decimal`, `bool`, `date`,
|
- **Types:** `text`, `int`, `real`, `decimal`, `bool`, `date`,
|
||||||
`datetime`, `blob`, `serial`, `shortid`. Compound primary keys
|
`datetime`, `serial`, `shortid`. Compound primary keys
|
||||||
supported. No real UUIDs (ADR-0005). FK column type
|
supported. No real UUIDs; `blob` dropped (ADR-0005 Amendment 2). FK column type
|
||||||
compatibility via `Type::fk_target_type()` — `serial → int`,
|
compatibility via `Type::fk_target_type()` — `serial → int`,
|
||||||
`shortid → text`, others identity (ADR-0011).
|
`shortid → text`, others identity (ADR-0011).
|
||||||
- **Safety:** append-only `history.log` for replay and scripting
|
- **Safety:** append-only `history.log` for replay and scripting
|
||||||
@@ -108,58 +108,95 @@ Current decisions at a glance (each backed by an ADR):
|
|||||||
SQL `select` / `with` / `insert` / `update` / `delete`
|
SQL `select` / `with` / `insert` / `update` / `delete`
|
||||||
(ADR-0039). `EXPLAIN QUERY PLAN` never executes, so
|
(ADR-0039). `EXPLAIN QUERY PLAN` never executes, so
|
||||||
explaining a destructive command is safe.
|
explaining a destructive command is safe.
|
||||||
- **Continuous integration & release** (built on the `ci` branch,
|
- **Continuous integration & release** (developed on the `ci` branch,
|
||||||
2026-06-15; decisions in `docs/ci/adr/` — **ADR-ci-001/002/003**,
|
**merged to `main` 2026-06-15**; decisions in `docs/ci/adr/` —
|
||||||
a namespace kept separate from the main ADR sequence to avoid
|
**ADR-ci-001/002/003**, a namespace kept separate from the main ADR
|
||||||
cross-branch number collisions, like the website's): a self-hosted
|
sequence to avoid cross-branch number collisions, like the website's):
|
||||||
|
a self-hosted
|
||||||
**Gitea Actions** pipeline built on a **nix flake** (pinned Rust
|
**Gitea Actions** pipeline built on a **nix flake** (pinned Rust
|
||||||
`1.95.0` — one source of toolchain for dev *and* CI) plus a
|
`1.95.0` — one source of toolchain for dev *and* CI) plus a
|
||||||
prebuilt CI image. **Gate** (`ci.yaml`): `clippy -D warnings` +
|
prebuilt CI image. **Gate** (`ci.yaml`, every step run via `nix
|
||||||
`cargo test` on every branch push / PR. **Release** on a `v*` tag
|
develop -c` so dev and CI share the pinned toolchain): `cargo fmt
|
||||||
|
--check` + `clippy -D warnings` + `cargo test --no-fail-fast` on
|
||||||
|
every branch push / PR. **Verify locally the CI way before pushing**
|
||||||
|
— `nix develop -c cargo fmt --check`, `nix develop -c cargo clippy
|
||||||
|
--all-targets -- -D warnings`, `nix develop -c cargo test` — and read
|
||||||
|
the **exit code**, not piped output (a `cargo fmt --check | tail &&
|
||||||
|
echo clean` masks the real result; a bare host `cargo` may also differ
|
||||||
|
from the pinned toolchain). **Release** on a `v*` tag
|
||||||
(`release.yaml`): the four non-macOS **D1** targets cross-built
|
(`release.yaml`): the four non-macOS **D1** targets cross-built
|
||||||
with `cargo-zigbuild` (Linux musl static + standalone Windows
|
with `cargo-zigbuild` (Linux musl static + standalone Windows
|
||||||
`.exe`); the two macOS targets via the **dispatched**
|
`.exe`); the two macOS targets via the **dispatched**
|
||||||
`release-macos.yaml` on a Tart Apple-Silicon runner (de-nix the
|
`release-macos.yaml` on a Tart Apple-Silicon runner (de-nix the
|
||||||
`libiconv` load path + ad-hoc re-sign). All published to a Gitea
|
`libiconv` load path + ad-hoc re-sign). All published to a Gitea
|
||||||
release with `.sha256`s. **`fmt` is intentionally not gated yet**
|
release with `.sha256`s. **`fmt` is gated** (`cargo fmt --check`,
|
||||||
(the tree isn't stock-`rustfmt`-clean). `workflow_dispatch` is
|
stock defaults — enabled once the tree was reformatted on `main`,
|
||||||
Gitea-default-branch-only, so `release-macos` is dispatchable once
|
ADR-ci-002 Amendment 1 / issue #35). Now that this is on `main`,
|
||||||
this lands 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
|
## Repository layout
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── Cargo.toml # dependencies, lints (nursery)
|
├── Cargo.toml / Cargo.lock # dependencies, lints (nursery)
|
||||||
├── CLAUDE.md # this file
|
├── 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/
|
├── 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
|
│ ├── handoff/ # session-handover notes
|
||||||
|
│ ├── plans/ # working plans
|
||||||
│ └── requirements.md # the Phase-1 checklist with progress
|
│ └── requirements.md # the Phase-1 checklist with progress
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── action.rs # Action enum (Quit / ExecuteDsl)
|
│ ├── action.rs # Action enum (Quit / ExecuteDsl / …)
|
||||||
│ ├── app.rs # App state + pure update() + Tier-1 tests
|
│ ├── 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
|
│ ├── db.rs # rusqlite worker, all DDL/DML, metadata tables
|
||||||
│ ├── dsl/
|
│ ├── dsl/
|
||||||
│ │ ├── action.rs # ReferentialAction enum + parsing
|
│ │ ├── grammar/ # hand-rolled unified grammar nodes (DSL + SQL)
|
||||||
│ │ ├── command.rs # Command AST + RelationshipSelector + RowFilter
|
│ │ ├── walker/ # grammar walker (driver / context / highlight / outcome)
|
||||||
│ │ ├── mod.rs # re-exports
|
│ │ ├── command.rs parser.rs types.rs value.rs action.rs shortid.rs sql_functions.rs
|
||||||
│ │ ├── parser.rs # parse entry point → unified-grammar walker
|
│ ├── echo.rs # command echo / SQL rendering
|
||||||
│ │ ├── shortid.rs # base58 generator + validator
|
|
||||||
│ │ ├── types.rs # user-facing Type enum + fk_target_type
|
|
||||||
│ │ └── value.rs # Value/Bound + per-type validation
|
|
||||||
│ ├── event.rs # AppEvent (input + DSL outcomes)
|
│ ├── event.rs # AppEvent (input + DSL outcomes)
|
||||||
│ ├── lib.rs # module re-exports for tests
|
│ ├── friendly/ # friendly-error layer + string catalog (strings/en-US.yaml) + keys
|
||||||
│ ├── logging.rs # tracing setup, file-backed
|
│ ├── input_render.rs # input-field render + ambient hint classification
|
||||||
│ ├── main.rs # binary entry; thin
|
│ ├── output_render.rs # output-panel render helpers (incl. relationship diagrams)
|
||||||
│ ├── mode.rs # Simple/Advanced mode enum
|
│ ├── logging.rs main.rs mode.rs runtime.rs # tracing / entry / mode enum / Tokio loop
|
||||||
│ ├── runtime.rs # Tokio loop, terminal setup, dispatch
|
│ ├── 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
|
│ ├── snapshots/ # insta snapshots for Tier-2 tests
|
||||||
│ ├── theme.rs # light/dark themes
|
│ ├── theme.rs type_change.rs ui.rs undo.rs # themes / column type-change / render / undo ring
|
||||||
│ └── ui.rs # ratatui rendering
|
│ └── lib.rs # module re-exports for tests
|
||||||
└── tests/
|
├── tests/
|
||||||
└── walking_skeleton.rs # Tier-3 integration 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:
|
Key invariants in the code:
|
||||||
@@ -182,7 +219,10 @@ Key invariants in the code:
|
|||||||
ADR. In-flight discussion stays in conversation or issues
|
ADR. In-flight discussion stays in conversation or issues
|
||||||
until it settles. The ADR-0000 index-upkeep rule applies:
|
until it settles. The ADR-0000 index-upkeep rule applies:
|
||||||
every ADR change updates `docs/adr/README.md` in the same
|
every ADR change updates `docs/adr/README.md` in the same
|
||||||
edit.
|
edit. ADR **numbers** are assigned at merge-to-`main` (draft
|
||||||
|
under a placeholder `ADR-XXXX` / `draft-<slug>.md` on a
|
||||||
|
branch) to avoid cross-branch collisions — see ADR-0000
|
||||||
|
"Numbering discipline".
|
||||||
- **Issue tracking.** Bugs and enhancements are filed as Gitea
|
- **Issue tracking.** Bugs and enhancements are filed as Gitea
|
||||||
issues (see *Issue tracking — Gitea via `tea`* below).
|
issues (see *Issue tracking — Gitea via `tea`* below).
|
||||||
`docs/requirements.md` and the ADRs remain the source of truth
|
`docs/requirements.md` and the ADRs remain the source of truth
|
||||||
@@ -214,10 +254,64 @@ Key invariants in the code:
|
|||||||
the specific product (SQLite, STRICT, rusqlite, PRAGMA).
|
the specific product (SQLite, STRICT, rusqlite, PRAGMA).
|
||||||
ADR-internal prose and code comments may name it where
|
ADR-internal prose and code comments may name it where
|
||||||
technically necessary for precision.
|
technically necessary for precision.
|
||||||
|
- **Changelog discipline.** `CHANGELOG.md` (repo root) tracks
|
||||||
|
notable **user-facing** changes (Keep a Changelog + SemVer).
|
||||||
|
Update it in the **same change** that introduces a user-facing
|
||||||
|
behaviour change: add or amend a bullet under `[Unreleased]`
|
||||||
|
in the right category (Added / Changed / Deprecated / Removed /
|
||||||
|
Fixed / Security), phrased for end users under the two copy
|
||||||
|
rules above (no engine name; no "DSL" — say "simple mode" /
|
||||||
|
"advanced mode"). A change with no user-visible effect (pure
|
||||||
|
refactor, internal tests, CI plumbing) gets **no** entry — and
|
||||||
|
the judgement of "user-facing" includes scripted / pasted /
|
||||||
|
power-user paths, not just the interactive happy path. At
|
||||||
|
release time, rename `[Unreleased]` to the new version + date,
|
||||||
|
add the compare link, and **sweep the commits/handoffs since
|
||||||
|
the last tag** as a backstop for anything missed.
|
||||||
- **Confirm commits.** Per the user's global rules, every
|
- **Confirm commits.** Per the user's global rules, every
|
||||||
`git commit` is preceded by an explicit message proposal
|
`git commit` is preceded by an explicit message proposal
|
||||||
and user approval. No AI attribution in commit messages.
|
and user approval. No AI attribution in commit messages.
|
||||||
|
|
||||||
|
## Branch-and-PR working method (ADR-0059)
|
||||||
|
|
||||||
|
The trackable working method for the now-public repo. Full rationale +
|
||||||
|
forks in **ADR-0059**; the public-facing subset is `CONTRIBUTING.md`. The
|
||||||
|
operational rules an agent must follow:
|
||||||
|
|
||||||
|
- **Everything lands via a PR onto a protected `main`.** No direct pushes
|
||||||
|
to `main` (one exception: the ADR-reservation ledger line, D4/D5). One
|
||||||
|
logical change per branch; conventional prefixes (`feat/ fix/ docs/
|
||||||
|
chore/ refactor/ test/ ci/`) matching the commit type.
|
||||||
|
- **One worktree per branch — never switch the primary checkout.** Start a
|
||||||
|
branch with `scripts/wt-new.sh <branch>` (creates branch off `origin/main`
|
||||||
|
with `--no-track` + a sibling `<repo>-worktree-<segment>` worktree; first
|
||||||
|
push is `git push -u origin <branch>`); remove a finished one
|
||||||
|
with `scripts/wt-rm.sh <branch>` (removes only the *named* worktree — it
|
||||||
|
never scans or auto-removes, so long-lived branches like `website`/`ci`
|
||||||
|
are never at risk). This prevents the same-directory clobber hazard.
|
||||||
|
- **Reserve ADR numbers up front** the moment an ADR is known to be needed
|
||||||
|
(branch start *or* mid-branch): `scripts/adr-reserve.sh <slug> "<title>"`
|
||||||
|
atomically claims the next number against `main` (ledger
|
||||||
|
`docs/adr/RESERVATIONS.log`; push = compare-and-swap, retried). The
|
||||||
|
number is stable from creation — cite it freely in commits/cross-refs.
|
||||||
|
**The human runs this script** (its push must originate from them);
|
||||||
|
agents write/test it but don't run its pushing path. Then create
|
||||||
|
`docs/adr/<NNNN>-<slug>.md` + its README index row as normal branch work.
|
||||||
|
- **Merge with `--no-ff` merge commits; never rebase or squash** (append-
|
||||||
|
only history). Push and merge are **human steps** — agents prepare
|
||||||
|
branches, edits, PR text, and commit-message proposals, but never push
|
||||||
|
or merge.
|
||||||
|
- **Paste the `/runda` / DA review into the PR** so the public repo carries
|
||||||
|
the audit trail.
|
||||||
|
- **Handoff / session docs are owner-direct-pushed to `main`, not PR'd** (a
|
||||||
|
deliberate extension of the reservation-ledger carve-out). Reason: a
|
||||||
|
docs-only change is `paths-ignore`d by `ci.yaml`, so it posts no `ci / gate`
|
||||||
|
run — and branch protection *requires* `ci / gate*`, so a docs-only PR
|
||||||
|
stalls forever on a required check that never arrives. Direct-push (owner
|
||||||
|
is whitelisted, docs paths burn no CI) sidesteps that. The same applies to
|
||||||
|
small docs amendments like this one. If docs-via-PR is ever wanted, first
|
||||||
|
add a "skipped → success" gate shim (or drop the docs paths-ignore).
|
||||||
|
|
||||||
## Issue tracking — Gitea via `tea`
|
## Issue tracking — Gitea via `tea`
|
||||||
|
|
||||||
Extends (does not replace) the generic Gitea/`tea` safety rules in
|
Extends (does not replace) the generic Gitea/`tea` safety rules in
|
||||||
@@ -335,16 +429,8 @@ all of `target/`, forcing a full from-scratch rebuild).
|
|||||||
These are explicitly tracked (mostly in `requirements.md`) but
|
These are explicitly tracked (mostly in `requirements.md`) but
|
||||||
not yet implemented:
|
not yet implemented:
|
||||||
|
|
||||||
- **Project storage** (track 2): largely implemented through
|
|
||||||
Iteration 4 + cleanup pass + safety hardening (Iterations
|
|
||||||
1–4 of ADR-0015). Pending pieces: `export` / `import` (Iter
|
|
||||||
5), `--resume` + persistent input history hydration +
|
|
||||||
migration framework scaffold (Iter 6).
|
|
||||||
- **Modify relationship** (C3a): drop+add covers the use case
|
- **Modify relationship** (C3a): drop+add covers the use case
|
||||||
today.
|
today.
|
||||||
- **m:n convenience** (C4): auto-generates a junction table
|
|
||||||
with appropriate FKs — depends on relationships being solid
|
|
||||||
(they are).
|
|
||||||
- **Strong syntax-help in parse errors** (H1a): point users at
|
- **Strong syntax-help in parse errors** (H1a): point users at
|
||||||
missing keywords/clauses rather than the unexpected
|
missing keywords/clauses rather than the unexpected
|
||||||
character. *(H1 — the friendly **database**-error layer — is
|
character. *(H1 — the friendly **database**-error layer — is
|
||||||
@@ -355,11 +441,8 @@ not yet implemented:
|
|||||||
- **Session log + Markdown export** (V4): the bigger UX
|
- **Session log + Markdown export** (V4): the bigger UX
|
||||||
project — scrollable session journal, smart structure
|
project — scrollable session journal, smart structure
|
||||||
rendering, save-as-markdown.
|
rendering, save-as-markdown.
|
||||||
- **Readline shortcuts** (I1b): Ctrl-A/Ctrl-E, Ctrl-W/Ctrl-K/
|
|
||||||
Ctrl-U.
|
|
||||||
- **Multi-line input** (I1): Enter inserts newline,
|
- **Multi-line input** (I1): Enter inserts newline,
|
||||||
Ctrl-Enter submits.
|
Ctrl-Enter submits.
|
||||||
- **Tab completion** (I3), **syntax highlighting** (I4).
|
|
||||||
- **ER diagram export** (V3).
|
- **ER diagram export** (V3).
|
||||||
- **Full TT5** (CI): the pipeline is live (see the CI decision
|
- **Full TT5** (CI): the pipeline is live (see the CI decision
|
||||||
above / `docs/ci/adr/`), but "all tiers on all OSes" isn't
|
above / `docs/ci/adr/`), but "all tiers on all OSes" isn't
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Getting set up
|
||||||
|
|
||||||
|
The toolchain is pinned with a Nix flake, so dev and CI share one Rust
|
||||||
|
version. With [Nix](https://nixos.org/download) (flakes enabled):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix develop # a shell with the pinned toolchain
|
||||||
|
```
|
||||||
|
|
||||||
|
Run everything through `nix develop -c …` so you match CI exactly.
|
||||||
|
|
||||||
|
## The checks your change must pass
|
||||||
|
|
||||||
|
CI runs these on every push and PR; run them locally first, and **check the
|
||||||
|
exit code** (a piped `… | tail` can hide a failure):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix develop -c cargo fmt --check
|
||||||
|
nix develop -c cargo clippy --all-targets -- -D warnings
|
||||||
|
nix develop -c cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
All three must be green, with no skipped tests. New behaviour needs tests —
|
||||||
|
the suite runs in tiers from unit up to a PTY-driven end-to-end harness.
|
||||||
|
|
||||||
|
## Branches and pull requests
|
||||||
|
|
||||||
|
- **`main` is protected** and always green; it isn't pushed to directly.
|
||||||
|
Every change lands through a pull request.
|
||||||
|
- **Branch off `main`**, one logical change per branch, named with a
|
||||||
|
conventional prefix: `feat/…`, `fix/…`, `docs/…`, `chore/…`,
|
||||||
|
`refactor/…`, `test/…`, `ci/…`.
|
||||||
|
- **Commit messages** follow [Conventional Commits](https://www.conventionalcommits.org/)
|
||||||
|
(`feat:`, `fix:`, `docs:` …) and reference the issue (`… (#123)` /
|
||||||
|
`Closes #123`).
|
||||||
|
- **Open a PR against `main`.** The CI gate must pass before it can merge;
|
||||||
|
PRs land as merge commits — history is append-only, so please don't
|
||||||
|
force-push or rebase shared branches.
|
||||||
|
- Keep one PR to one concern; don't fold unrelated changes together.
|
||||||
|
|
||||||
|
## User-facing text
|
||||||
|
|
||||||
|
Two rules bind anything a user can see (errors, help, notes):
|
||||||
|
|
||||||
|
- **Don't name the database engine** — say "the database" / "the engine".
|
||||||
|
- **Don't say "DSL"** — say "simple mode" / "advanced mode".
|
||||||
|
|
||||||
|
## Significant changes get a decision record
|
||||||
|
|
||||||
|
Architectural or otherwise consequential changes are recorded as ADRs in
|
||||||
|
[`docs/adr/`](docs/adr/) — start with [`docs/adr/README.md`](docs/adr/README.md).
|
||||||
|
If your change touches a decided area, read the relevant ADR first; if it
|
||||||
|
would change a decision, propose a new ADR rather than quietly diverging.
|
||||||
|
Opening an issue to discuss a substantial change before building it is
|
||||||
|
always welcome.
|
||||||
|
|
||||||
|
## Code style
|
||||||
|
|
||||||
|
Match the surrounding code — its naming, comment density, and idioms. The
|
||||||
|
Clippy nursery lints are enabled and must pass clean.
|
||||||
|
|
||||||
|
## 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
+114
-4
@@ -64,6 +64,12 @@ dependencies = [
|
|||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic"
|
name = "atomic"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -164,6 +170,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg_aliases"
|
name = "cfg_aliases"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -481,6 +493,12 @@ dependencies = [
|
|||||||
"litrs",
|
"litrs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "downcast-rs"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -976,7 +994,7 @@ version = "1.1.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nix",
|
"nix 0.29.0",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1038,6 +1056,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases 0.1.1",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -1046,7 +1076,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"libc",
|
"libc",
|
||||||
"memoffset",
|
"memoffset",
|
||||||
]
|
]
|
||||||
@@ -1360,6 +1390,27 @@ version = "1.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-pty"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"downcast-rs",
|
||||||
|
"filedescriptor",
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"nix 0.28.0",
|
||||||
|
"serial2",
|
||||||
|
"shared_library",
|
||||||
|
"shell-words",
|
||||||
|
"winapi",
|
||||||
|
"winreg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "powerfmt"
|
name = "powerfmt"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1535,7 +1586,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rdbms-playground"
|
name = "rdbms-playground"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arboard",
|
"arboard",
|
||||||
@@ -1548,6 +1599,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"gethostname",
|
"gethostname",
|
||||||
"insta",
|
"insta",
|
||||||
|
"portable-pty",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
@@ -1559,6 +1611,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"vt100",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1738,6 +1791,17 @@ dependencies = [
|
|||||||
"unsafe-libyaml-norway",
|
"unsafe-libyaml-norway",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serial2"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -1758,6 +1822,22 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shared_library"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shell-words"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -1968,7 +2048,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"memmem",
|
"memmem",
|
||||||
"nix",
|
"nix 0.29.0",
|
||||||
"num-derive",
|
"num-derive",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"ordered-float",
|
"ordered-float",
|
||||||
@@ -2240,6 +2320,27 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vt100"
|
||||||
|
version = "0.16.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"unicode-width",
|
||||||
|
"vte",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vte"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vtparse"
|
name = "vtparse"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -2639,6 +2740,15 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winreg"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
|
|||||||
+43
-2
@@ -1,12 +1,21 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rdbms-playground"
|
name = "rdbms-playground"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "A cross-platform TUI playground for learning relational databases."
|
description = "A cross-platform TUI playground for learning relational databases."
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://git.lazyeval.net/oli/rdbms-playground"
|
repository = "https://git.lazyeval.net/oli/rdbms-playground"
|
||||||
|
homepage = "https://relplay.org"
|
||||||
readme = "README.md"
|
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]
|
[dependencies]
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
@@ -50,8 +59,10 @@ zip = { version = "5.1.1", default-features = false, features = ["deflate"] }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
insta = { version = "1.47.2", features = ["yaml"] }
|
insta = { version = "1.47.2", features = ["yaml"] }
|
||||||
|
portable-pty = "0.9"
|
||||||
pretty_assertions = "1.4.1"
|
pretty_assertions = "1.4.1"
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
|
vt100 = "0.16"
|
||||||
|
|
||||||
# Dev/test build hygiene (see CLAUDE.md "Build hygiene"). `cargo test`
|
# Dev/test build hygiene (see CLAUDE.md "Build hygiene"). `cargo test`
|
||||||
# links ~25 separate integration-test binaries, each statically
|
# links ~25 separate integration-test binaries, each statically
|
||||||
@@ -85,3 +96,33 @@ nursery = { level = "warn", priority = -1 }
|
|||||||
module_name_repetitions = "allow"
|
module_name_repetitions = "allow"
|
||||||
missing_errors_doc = "allow"
|
missing_errors_doc = "allow"
|
||||||
missing_panics_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).
|
||||||
@@ -38,6 +38,49 @@ The index lists ADRs in numerical order. Each entry shows the
|
|||||||
number, title, and — where relevant — status annotations such as
|
number, title, and — where relevant — status annotations such as
|
||||||
"Superseded by ADR-NNNN" or "Deprecated".
|
"Superseded by ADR-NNNN" or "Deprecated".
|
||||||
|
|
||||||
|
## Numbering discipline
|
||||||
|
|
||||||
|
ADR numbers are a single global sequence, so two branches can each grab
|
||||||
|
"the next number" independently and collide on merge. (This happened when
|
||||||
|
the `website` branch's ADR-0042 met `main`'s ADR-0042, resolved by
|
||||||
|
renumbering the former to ADR-0044.) To prevent it:
|
||||||
|
|
||||||
|
**Reserve the number up front, via `scripts/adr-reserve.sh`** (ADR-0059,
|
||||||
|
which superseded the earlier placeholder-until-merge default). The moment
|
||||||
|
you know an ADR is needed — at branch start or mid-branch — run
|
||||||
|
`scripts/adr-reserve.sh <slug> "<title>"`. It atomically claims the next
|
||||||
|
free number against `main` (the remote ref is the registry; a `git push`
|
||||||
|
is the compare-and-swap, retried on contention) and records it in the
|
||||||
|
append-only ledger `docs/adr/RESERVATIONS.log`. The number is then **stable
|
||||||
|
from creation**, so it is safe to cite in commit messages (immutable under
|
||||||
|
the no-rewrite rule), in other ADRs, and in the PR from the first commit.
|
||||||
|
Create the ADR as `docs/adr/<NNNN>-<slug>.md` and add its README index row
|
||||||
|
as part of the branch's normal work.
|
||||||
|
|
||||||
|
A number is "taken" once its ledger line (or `NNNN-*.md` file) is on
|
||||||
|
`main`; the script reads both to compute the next free number — never
|
||||||
|
compute "next" by hand from a feature branch. The full rationale (why
|
||||||
|
reserve-first beats number-on-merge, issue-number ids, or an allocator bot)
|
||||||
|
is in **ADR-0059**.
|
||||||
|
|
||||||
|
### Subproject ADR namespaces
|
||||||
|
|
||||||
|
A long-lived subproject developed on its own branch can escape the shared
|
||||||
|
integer pool entirely by keeping its decision records in a **separate
|
||||||
|
namespace**, rather than fighting collisions on every merge. The **website**
|
||||||
|
(`docs/website/adr/`) is the first: its ADRs use a dated sequence —
|
||||||
|
`<date>-adr-website-<NNN>.md`, referenced in prose as `ADR-website-NNN` —
|
||||||
|
and are indexed by their own `docs/website/adr/README.md`. Because the
|
||||||
|
date-plus-subproject prefix is disjoint from `main`'s integer sequence, a
|
||||||
|
website ADR and a `main` ADR can never claim "the same number" again. (This
|
||||||
|
namespace was created on 2026-06-10 after the website's ADR collided with
|
||||||
|
`main`'s on consecutive numbers — drafted 0042, bumped to 0044, both times
|
||||||
|
landing on a number `main` had taken; the move retired it from the pool as
|
||||||
|
**ADR-website-001**.) The main `docs/adr/` index carries a pointer to each
|
||||||
|
such namespace. Use this for a new subproject only when it is genuinely
|
||||||
|
self-contained and branch-isolated; one-off cross-cutting decisions stay in
|
||||||
|
the global sequence.
|
||||||
|
|
||||||
## Out-of-scope discipline
|
## Out-of-scope discipline
|
||||||
|
|
||||||
ADRs (and the plans they spawn) lean heavily on "out of scope" language.
|
ADRs (and the plans they spawn) lean heavily on "out of scope" language.
|
||||||
|
|||||||
@@ -132,3 +132,145 @@ verbatim, trailing zeros and all (`100.10`). Exact decimal
|
|||||||
require rewriting the user's standard-SQL operators into function
|
require rewriting the user's standard-SQL operators into function
|
||||||
calls, defeating both the "validated SQL runs verbatim" model and
|
calls, defeating both the "validated SQL runs verbatim" model and
|
||||||
the goal of teaching ordinary SQL.
|
the goal of teaching ordinary SQL.
|
||||||
|
|
||||||
|
## Amendment 2 — drop `blob` from the vocabulary (2026-06-22)
|
||||||
|
|
||||||
|
**This supersedes the `blob` row of the Decision table.** The type
|
||||||
|
set is now **nine types**: `text`, `int`, `real`, `decimal`, `bool`,
|
||||||
|
`date`, `datetime`, `serial`, `shortid`.
|
||||||
|
|
||||||
|
### Why `blob` is removed
|
||||||
|
|
||||||
|
The original Decision listed `blob` purely to mirror the engine's
|
||||||
|
storage classes (its only note was "Binary data."). Unlike the two
|
||||||
|
other vocabulary judgements made here — compound PKs *defended*
|
||||||
|
("skipping them would teach the wrong lesson") and true UUIDs
|
||||||
|
*excluded* ("in favour of TUI legibility") — `blob` never received the
|
||||||
|
same pedagogical scrutiny. It rode along as the fourth storage class
|
||||||
|
and was never re-examined.
|
||||||
|
|
||||||
|
In practice `blob` is a **dead-end type** in this application:
|
||||||
|
|
||||||
|
- **No value can be entered for it through the command surface.**
|
||||||
|
There is no blob literal in either mode — neither the simplified
|
||||||
|
syntax nor advanced SQL has one (ADR-0014: "DSL literal not
|
||||||
|
supported this iteration"; ADR-0038: "blob has no literal … neither
|
||||||
|
the DSL nor advanced SQL has a blob-literal syntax"). The binder
|
||||||
|
refuses outright (`src/dsl/value.rs`).
|
||||||
|
- **`seed` cannot generate it** (ADR-0048: `blob → unsupported`), and a
|
||||||
|
`NOT NULL blob` column is a hard refusal.
|
||||||
|
- So a blob column can only ever hold NULL via normal use. The only
|
||||||
|
way bytes get in at all is hand-editing the base64 cell in a CSV —
|
||||||
|
not something the app teaches or surfaces.
|
||||||
|
|
||||||
|
The missing literal was filed as a perpetual "pre-existing gap" across
|
||||||
|
many handoffs and never actioned. The right resolution is not to build
|
||||||
|
a blob-entry affordance — **there is nothing natural to *do* with
|
||||||
|
binary data in a terminal teaching tool, and nothing about the `blob`
|
||||||
|
type that serves this app's learning goals** (pedagogy wins ties; this
|
||||||
|
app is not the place to teach storage-class breadth). Removing it makes
|
||||||
|
the type set smaller (still "teachable in five minutes", now with one
|
||||||
|
less dead-end), removes a standing copy-rule bug (the binder's refusal
|
||||||
|
message named the internal "DSL" term — flagged 2026-06-22), and
|
||||||
|
simplifies the type-change matrix (ADR-0017's `↔ blob` static refusals
|
||||||
|
vanish) and the seed generator.
|
||||||
|
|
||||||
|
### What this changes
|
||||||
|
|
||||||
|
- `Type::Blob` and its keyword/aliases (`blob`, `binary`, `varbinary`)
|
||||||
|
are removed. Because the variant set is the single source of truth,
|
||||||
|
this clears both parse paths at once — `from_sql_name` (the
|
||||||
|
alias-aware command path) *and* `FromStr`/`parse::<Type>()` (the
|
||||||
|
keyword-only path the `.db` metadata read uses) — once the explicit
|
||||||
|
`blob` arms are deleted (compiler-guided). The type slot offered by
|
||||||
|
completion and the advanced-SQL grammar (`BLOB_SLOT`) loses `blob`;
|
||||||
|
`output_render`/`describe` lose the `BLOB` rendering. The data-layer
|
||||||
|
value plumbing it fed (`Value::Blob` / `CellValue::Blob(Vec<u8>)` and
|
||||||
|
the base64 CSV encode/decode in `persistence::csv_io`) is removed with
|
||||||
|
it.
|
||||||
|
- **User-facing strings** lose their blob entries: the `help types`
|
||||||
|
reference (catalog `help.types_reference`) drops `blob` from the
|
||||||
|
ten-name list, and the blob value-slot hint ("Type a quoted blob
|
||||||
|
literal or null", `hint.value_slot_blob`) — plus its `keys.rs`
|
||||||
|
declaration and the walker mapping — is removed. The binder's blob
|
||||||
|
refusal in `src/dsl/value.rs` (whose message named the internal "DSL"
|
||||||
|
term — the copy-rule bug that surfaced this whole question) is deleted
|
||||||
|
outright, so that fix needs no separate rewording.
|
||||||
|
- The `base64` dependency **stays** — `clipboard.rs` uses it for OSC-52,
|
||||||
|
independent of `blob`.
|
||||||
|
- ADR-0011 (FK target-type compatibility) and ADR-0017 (type-change
|
||||||
|
compatibility) lose their `blob` rows; ADR-0014/0038/0048's
|
||||||
|
blob-literal/seed notes become moot. Prose that says "ten-type"
|
||||||
|
(ADR-0030/0033/0035, `CLAUDE.md`, the website type reference) becomes
|
||||||
|
"nine-type".
|
||||||
|
|
||||||
|
### Backward compatibility — a project migration
|
||||||
|
|
||||||
|
`blob` was a creatable column type, so a saved project may name it. The
|
||||||
|
type appears in **two artifacts**, and the migration must reconcile
|
||||||
|
both:
|
||||||
|
|
||||||
|
1. **`project.yaml`** (authoritative) — a column declared `type: blob`.
|
||||||
|
On load this is parsed by `from_sql_name`, which returns `None` once
|
||||||
|
the type is gone → the project would fail to open.
|
||||||
|
2. **The derived `playground.db`** (ADR-0004/0015) — which carries the
|
||||||
|
type *twice over*: the user-facing string `"blob"` in the
|
||||||
|
`__rdbms_playground_columns` metadata table, **and** an actual
|
||||||
|
`STRICT … BLOB` column in the SQLite schema. Crucially, load uses an
|
||||||
|
existing `.db` **as-is** — `rebuild_from_text` runs only when the
|
||||||
|
`.db` is *missing* (`runtime.rs`, ADR-0015 §7). Worse, the metadata
|
||||||
|
read parses the stored type with `parse::<Type>().ok()` and falls
|
||||||
|
back to `Type::Text` on failure — so a stale blob `.db` would *not*
|
||||||
|
error; it would silently read the column as `text` while the engine
|
||||||
|
column stays `BLOB`: an inconsistent, broken state.
|
||||||
|
|
||||||
|
**So a YAML-only type-swap is insufficient.** The migration is:
|
||||||
|
|
||||||
|
1. **Migrate the YAML** via the ADR-0015 framework
|
||||||
|
(`src/persistence/migrations.rs`): register the first real migrator
|
||||||
|
(v1 → v2) that rewrites every `type: blob` to `type: text`. This is
|
||||||
|
the framework's first production use — note its by-design
|
||||||
|
consequence: bumping `latest_version` to 2 re-versions **every**
|
||||||
|
project on next load (a `project.yaml.v1.bak` is written, the body
|
||||||
|
rewritten) — harmless for blob-free projects (a pure version bump),
|
||||||
|
but it does touch every file once.
|
||||||
|
2. **Force a rebuild of the `.db` from the migrated YAML** whenever the
|
||||||
|
migration actually changed a column type (i.e. a `blob` was present)
|
||||||
|
— reusing the existing `rebuild_from_text` path so the engine schema
|
||||||
|
and metadata are regenerated as `text`, not left stale. A pure
|
||||||
|
version bump with no schema change does **not** force a rebuild.
|
||||||
|
|
||||||
|
**Conversion target: `text`** (decided 2026-06-22). Non-destructive —
|
||||||
|
the CSV already stores blob cells as base64 *text*, so any bytes survive
|
||||||
|
as a recoverable string, and an empty column (the overwhelmingly common
|
||||||
|
case) simply becomes an empty `text` column. It is constraint-safe:
|
||||||
|
`text` is a valid primary-key and FK-target type (`fk_target_type` is
|
||||||
|
identity for both), so a former-blob PK or FK target stays valid. And it
|
||||||
|
keeps the migration **CSV-free**: the column keeps its name and position,
|
||||||
|
so every CSV header still matches the rebuilt schema (the rebuild's
|
||||||
|
strict header==schema check, `do_rebuild_from_text`) and the existing
|
||||||
|
cells decode as `text`.
|
||||||
|
|
||||||
|
The considered alternatives were rejected: **drop the column** is
|
||||||
|
cleaner semantically (there is no legitimately-entered content to keep)
|
||||||
|
but the strict header check means it would force the migration to *also*
|
||||||
|
rewrite every affected `data/<table>.csv` to strip the column — not
|
||||||
|
worth the machinery for a type that has existed unusably for a few days
|
||||||
|
with very few users; and **refuse to load** strands the project. The
|
||||||
|
rebuild-on-migration step (above) is required regardless of target,
|
||||||
|
because the stale `.db` keeps a `STRICT … BLOB` *engine* column that can
|
||||||
|
neither hold text nor be reported as anything usable until regenerated.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- A smaller, fully-usable type set: every remaining type can be both
|
||||||
|
created *and* populated through the command surface.
|
||||||
|
- The type-change matrix and `seed` shed their blob special-cases; the
|
||||||
|
binder sheds its blob refusal (and its copy-rule violation).
|
||||||
|
- The ADR-0015 migration framework gets its first real exercise,
|
||||||
|
validating that path end-to-end.
|
||||||
|
- A breaking change for the rare project carrying a `blob` column,
|
||||||
|
mitigated to a graceful, non-destructive load by the migration.
|
||||||
|
- Re-introducing `blob` later (should a concrete teaching use appear)
|
||||||
|
is a new ADR superseding this amendment — the same bar every other
|
||||||
|
type change clears.
|
||||||
|
|||||||
@@ -147,3 +147,56 @@ and on a nightly schedule for any extended coverage.
|
|||||||
only slow tier and is kept narrow.
|
only slow tier and is kept narrow.
|
||||||
- Adding a feature implies adding tests at the appropriate tier
|
- Adding a feature implies adding tests at the appropriate tier
|
||||||
(or tiers); coverage is not retrofitted later.
|
(or tiers); coverage is not retrofitted later.
|
||||||
|
|
||||||
|
## Amendment 1 — 2026-06-22: Tier 4 realized (TT4)
|
||||||
|
|
||||||
|
Tier 4 was a specification only (no PTY deps, no tests) until this
|
||||||
|
amendment. It is now implemented in `tests/e2e_pty.rs`, and the realized
|
||||||
|
shape refines the original decision in three ways.
|
||||||
|
|
||||||
|
**Tooling — `expectrl` dropped.** The decision named
|
||||||
|
`portable-pty` + `expectrl` + `vt100`. We kept **`portable-pty`** (0.9, to
|
||||||
|
spawn the real binary in a PTY at a fixed size) and **`vt100`** (0.16, to
|
||||||
|
parse the output stream into an inspectable cell grid), both current and
|
||||||
|
maintained. We **dropped `expectrl`**: it bundles its own PTY abstraction
|
||||||
|
(which conflicts with `portable-pty`) and matches line-by-line, a poor fit
|
||||||
|
for a full-screen TUI. A small hand-rolled `wait_for(predicate, timeout)`
|
||||||
|
that polls the vt100 screen replaces it — fewer dependencies, and assertions
|
||||||
|
read the actual rendered grid.
|
||||||
|
|
||||||
|
**Harness shape.** Each test spawns the binary
|
||||||
|
(`env!("CARGO_BIN_EXE_rdbms-playground")`) under a fresh PTY at **100×30**
|
||||||
|
(the wide three-region layout, ADR-0046), with its **own temp `--data-dir`**
|
||||||
|
so it never touches real projects or resume state, and `--theme dark` for
|
||||||
|
deterministic styling. Tests run **serially** (one PTY + child at a time)
|
||||||
|
via a poison-tolerant global lock, so timing stays predictable on the
|
||||||
|
low-parallelism self-hosted runner. Waits are **tight and fail-fast** (3 s):
|
||||||
|
a slow wait is a genuine hang, and a timeout panics with a full screen dump
|
||||||
|
for debuggability.
|
||||||
|
|
||||||
|
Two non-obvious robustness rules the harness encodes:
|
||||||
|
- **Read table presence from the Tables *sidebar* region, not the whole
|
||||||
|
screen** — the Output panel echoes every command, so a table name typed
|
||||||
|
in a command would pollute a whole-screen match.
|
||||||
|
- **Pace each command to completion before the next** (real-user / cast
|
||||||
|
cadence). A command submitted while the previous one's worker rebuild is
|
||||||
|
still in flight can be misread against a stale schema cache (issue #39,
|
||||||
|
discovered here — interactive use is unaffected).
|
||||||
|
|
||||||
|
**The four flows** mirror this ADR's Tier-4 scope: (1) cold launch → first
|
||||||
|
DDL → graceful quit (Ctrl-C); (2) create → quit → reopen via `--resume`,
|
||||||
|
asserting the *column* (not just the table name) round-trips; (3) export →
|
||||||
|
import into a fresh project → schema **and** data rebuilt; (4) `undo` after
|
||||||
|
`DROP TABLE`, through the Y/N confirm modal.
|
||||||
|
|
||||||
|
**CI.** The target runs by default in `cargo test`, so the existing Linux
|
||||||
|
gate exercises it on every push — this **advances TT5** (Tier 4 now runs in
|
||||||
|
CI on Linux). PTY-in-container was validated (openpty + child spawn work
|
||||||
|
under the Docker runtime the Gitea runner uses); the first real CI run is
|
||||||
|
the final confirmation. **Windows execution remains out of scope** (no
|
||||||
|
Windows runner), and the original Tier-4 note about a *nightly broader
|
||||||
|
coverage* schedule is **not** implemented — the focused four flows are the
|
||||||
|
committed TT4 scope; broader coverage is deferred.
|
||||||
|
|
||||||
|
**Piggybacked NFR measurement.** The same harness measures NFR-1 (startup
|
||||||
|
to first frame) and NFR-3 (idle RSS, Linux) — see ADR-0057.
|
||||||
@@ -824,6 +824,63 @@ of issue #4; no `AmbientHint` / renderer change. Covered by
|
|||||||
seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given,
|
seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given,
|
||||||
seed_count_hint_also_fires_after_a_column_fill_target}`.
|
seed_count_hint_also_fires_after_a_column_fill_target}`.
|
||||||
|
|
||||||
|
## Amendment 8 — submission gate: hold input until the schema-cache refresh lands (2026-06-22)
|
||||||
|
|
||||||
|
§9 established the schema cache as the source of truth for the
|
||||||
|
walker's schema-aware dispatch, "refreshed by the runtime … after
|
||||||
|
successful DDL". That refresh is **asynchronous**: the runtime applies
|
||||||
|
a command on the worker thread (ADR-0010) and only afterwards posts a
|
||||||
|
`SchemaCacheRefreshed` event back through the **same FIFO channel that
|
||||||
|
carries key events**. Between dispatching a DDL command and that event
|
||||||
|
landing, `schema_cache` is stale w.r.t. the command just run.
|
||||||
|
|
||||||
|
Validation in `App::update` is pure-sync (a core invariant — it cannot
|
||||||
|
do a DB round-trip), so it can only consult that cache. Under
|
||||||
|
**faster-than-human input** (paste, scripted input, an unpaced PTY
|
||||||
|
driver), the next submission's Enter is already queued *ahead* of the
|
||||||
|
refresh event, so it is validated against the stale cache. A simple-mode
|
||||||
|
**Form-B insert** (`insert into T values (…)`, columns derived from the
|
||||||
|
cache) submitted right after `add column` then sees the pre-DDL columns,
|
||||||
|
its value arity can't match, and the friendly layer tags it *"trying to
|
||||||
|
write SQL?"* — even though the identical line succeeds at human speed
|
||||||
|
(issue **#39**).
|
||||||
|
|
||||||
|
**Decision — gate submission on the pending refresh.** `App` carries
|
||||||
|
`awaiting_schema_refresh: bool` + a `held_submissions` FIFO queue.
|
||||||
|
`dispatch_dsl` arms the flag on **every** `ExecuteDsl` dispatch; while
|
||||||
|
armed, a new DSL submission is **held** (queued in submission order)
|
||||||
|
rather than validated. The `SchemaCacheRefreshed` handler clears the
|
||||||
|
flag and **drains** the queue against the now-fresh cache, stopping as
|
||||||
|
soon as a drained command re-arms the gate (so the remainder wait for
|
||||||
|
*its* refresh — order preserved). A held command that doesn't dispatch
|
||||||
|
(parse error / pre-flight rejection) leaves the gate open and the loop
|
||||||
|
continues. App-lifecycle commands (`quit` / `help` / `load` / `undo` /
|
||||||
|
`rebuild`) route through `dispatch_app_command` *before* `dispatch_dsl`
|
||||||
|
and so are never held.
|
||||||
|
|
||||||
|
**Why arm on every dispatch, not just DDL.** It keeps at most one DSL
|
||||||
|
command in flight, so refreshes are strictly one-per-dispatch and in
|
||||||
|
order — making the gate a provably-correct boolean. Arming only on
|
||||||
|
schema-mutating commands would let a *preceding* non-DDL command's
|
||||||
|
refresh clear the gate early and drain a held Form-B insert against a
|
||||||
|
cache that predates the DDL — reintroducing the bug in a corner case.
|
||||||
|
(App-lifecycle commands also refresh but bypass the gate; they are
|
||||||
|
modal/picker-gated and so cannot overlap a rapid DSL paste, the only
|
||||||
|
thing this guards.)
|
||||||
|
|
||||||
|
**Scope.** Interactive input only. The `replay` / history-log / startup
|
||||||
|
rebuild-from-text batch path already re-snapshots the schema
|
||||||
|
**synchronously, inline, before every line** (`run_replay`,
|
||||||
|
`build_schema_cache`), so it has always had this ordering guarantee;
|
||||||
|
this amendment brings the interactive path in line with it. No
|
||||||
|
interactive-user impact (the gate clears in milliseconds); held input is
|
||||||
|
never lost because the runtime sends a `SchemaCacheRefreshed` after
|
||||||
|
*every* dispatch, success or failure. Covered by
|
||||||
|
`app::tests::form_b_insert_after_ddl_is_held_until_refresh_then_dispatched`
|
||||||
|
(Tier-1, deterministic event ordering) and the Tier-4 PTY regression
|
||||||
|
`e2e_pty::back_to_back_insert_after_ddl_still_succeeds` (the unpaced
|
||||||
|
inverse of flow 3).
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
Deliberately deferred to keep this ADR shippable as a single
|
Deliberately deferred to keep this ADR shippable as a single
|
||||||
|
|||||||
@@ -706,6 +706,56 @@ documentation is still hand-curated for round 1.
|
|||||||
table-ident from new-name-ident visually is a future
|
table-ident from new-name-ident visually is a future
|
||||||
enhancement.
|
enhancement.
|
||||||
|
|
||||||
|
## Amendment 1 — advanced-mode SQL forms get their own `help` pages, and the list groups by mode (2026-06-22, issue #36)
|
||||||
|
|
||||||
|
`help_id` (§Node taxonomy) drives two surfaces: the `help` **list**
|
||||||
|
(`note_help` emits one `help.<id>` block per `Some` id) and the
|
||||||
|
`help <topic>` **lookup** (`note_help_topic` shows the block of every
|
||||||
|
node whose entry word matches the topic). The dedup rule is that
|
||||||
|
`help_id` *strings* are unique (one block ⇒ printed once).
|
||||||
|
|
||||||
|
Originally the six advanced-mode SQL **DML/query** forms — `SELECT`,
|
||||||
|
`WITH`, `SQL_INSERT`, `SQL_UPDATE`, `SQL_DELETE`, `EXPLAIN_SQL` —
|
||||||
|
carried `help_id: None`. That was a list-formatting shortcut (avoid a
|
||||||
|
second `insert` entry), but it had a side effect: `help select` /
|
||||||
|
`help with` resolved to **nothing** (the unknown-topic note), and
|
||||||
|
`help insert` showed only the simple form — even though the SQL surface
|
||||||
|
is genuinely different and advanced mode exists precisely for learners
|
||||||
|
moving to raw SQL. (The advanced SQL **DDL** forms — `sql_create_table`
|
||||||
|
etc. — already had their own `help.ddl.sql_*` pages, so the gap was
|
||||||
|
inconsistent as well as a pedagogy hole.)
|
||||||
|
|
||||||
|
**Decision.** Give every advanced SQL form its **own** `help_id`
|
||||||
|
(`data.select`, `data.with`, `data.sql_insert`, `data.sql_update`,
|
||||||
|
`data.sql_delete`, `data.explain_sql`) with a hand-curated
|
||||||
|
`help.data.*` page (the catalog body stays hand-written, per "What's
|
||||||
|
out of scope" above — this amendment doesn't change that). Because the
|
||||||
|
ids are **distinct strings**, the dedup invariant
|
||||||
|
(`no_two_registered_commands_share_a_help_id`) is untouched, and:
|
||||||
|
|
||||||
|
- **`help <topic>` shows every form sharing the entry word** — so
|
||||||
|
`help insert` shows the simple block *and* the `sql_insert` block
|
||||||
|
(exactly as `help create` already showed the simple + SQL create
|
||||||
|
forms). Advanced-only `help select` / `help with` now resolve.
|
||||||
|
- **The list groups by mode.** `note_help` now splits the REGISTRY by
|
||||||
|
[`CommandCategory`]: app-lifecycle commands (ids in the `app.*`
|
||||||
|
namespace, usable in either mode) list first, unlabelled, under the
|
||||||
|
intro; then a **`help.simple_section`** ("Simple-mode commands:")
|
||||||
|
group and a **`help.advanced_section`** ("Advanced-mode (SQL)
|
||||||
|
commands:") group. This replaces the single `help.dsl_section` header
|
||||||
|
("DSL data commands (in simple mode):"), which both used the banned
|
||||||
|
"DSL" term (ADR-0002 user-facing posture) and wrongly claimed simple
|
||||||
|
mode for the advanced SQL forms the section already held.
|
||||||
|
|
||||||
|
This partially realises ADR-0030 §6's "Polish" item (a `help sql`
|
||||||
|
page): rather than one combined page, each form has its own, reached
|
||||||
|
through the normal `help <topic>` surface and discoverable in the
|
||||||
|
advanced-mode list section. `note_help_topic` needed **no** change —
|
||||||
|
the new `help_id`s make the forms resolve automatically. Covered by
|
||||||
|
`help_command::{help_select_renders_the_sql_select_block,
|
||||||
|
help_with_renders_the_cte_block, help_insert_shows_both_simple_and_sql_forms,
|
||||||
|
help_list_splits_simple_and_advanced_sections}`.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- ADR-0023 — Unified declarative grammar tree (Proposed direction). Superseded by this ADR for execution detail.
|
- ADR-0023 — Unified declarative grammar tree (Proposed direction). Superseded by this ADR for execution detail.
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ representation; the SQL surface does not round-trip through it.
|
|||||||
|
|
||||||
### 5. Type vocabulary — the playground's, not the engine's
|
### 5. Type vocabulary — the playground's, not the engine's
|
||||||
|
|
||||||
Advanced-mode DDL uses the playground's own ten-type
|
Advanced-mode DDL uses the playground's own nine-type
|
||||||
vocabulary (ADR-0005). There is **no fallback to engine
|
vocabulary (ADR-0005). There is **no fallback to engine
|
||||||
storage types**: a column created in advanced mode is a
|
storage types**: a column created in advanced mode is a
|
||||||
first-class `serial` / `decimal` / `date` / … exactly as a
|
first-class `serial` / `decimal` / `date` / … exactly as a
|
||||||
@@ -373,7 +373,7 @@ ADR when taken up (ADR-0026-style).
|
|||||||
- ADR-0002 — the engine is an implementation detail; "no
|
- ADR-0002 — the engine is an implementation detail; "no
|
||||||
engine name in user-facing strings" — §7 extends it.
|
engine name in user-facing strings" — §7 extends it.
|
||||||
- ADR-0003 — the simple / advanced mode model this builds on.
|
- ADR-0003 — the simple / advanced mode model this builds on.
|
||||||
- ADR-0005 — the ten-type vocabulary advanced DDL uses (§5).
|
- ADR-0005 — the nine-type vocabulary advanced DDL uses (§5).
|
||||||
- ADR-0009 — the DSL conventions; the DSL stays usable in
|
- ADR-0009 — the DSL conventions; the DSL stays usable in
|
||||||
advanced mode.
|
advanced mode.
|
||||||
- ADR-0012 / ADR-0013 — the metadata tables the `Command` core
|
- ADR-0012 / ADR-0013 — the metadata tables the `Command` core
|
||||||
|
|||||||
@@ -1649,7 +1649,7 @@ more.
|
|||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- ADR-0005 — the ten-type vocabulary INSERT works with.
|
- ADR-0005 — the nine-type vocabulary INSERT works with.
|
||||||
- ADR-0006 — the auto-snapshot before destructive ops.
|
- ADR-0006 — the auto-snapshot before destructive ops.
|
||||||
- ADR-0014 — the DSL DML model + cascade-summary precedent.
|
- ADR-0014 — the DSL DML model + cascade-summary precedent.
|
||||||
- ADR-0015 — the persistence write-through path.
|
- ADR-0015 — the persistence write-through path.
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Two things from the earlier phases shape this one:
|
|||||||
executed as-is, the engine would make the table, but the
|
executed as-is, the engine would make the table, but the
|
||||||
playground would lose what the user meant: that `id` is `serial`,
|
playground would lose what the user meant: that `id` is `serial`,
|
||||||
that a `REFERENCES` clause is a *named relationship*, that `STRICT`
|
that a `REFERENCES` clause is a *named relationship*, that `STRICT`
|
||||||
applies, that the ten-type vocabulary governs. Recovering that
|
applies, that the nine-type vocabulary governs. Recovering that
|
||||||
needs the parsed statement either way.
|
needs the parsed statement either way.
|
||||||
|
|
||||||
ADR-0030 §4 said "DDL → a `Command` … run the typed executor." That
|
ADR-0030 §4 said "DDL → a `Command` … run the typed executor." That
|
||||||
@@ -92,7 +92,7 @@ mode. Unlike the DML `Sql*` commands they **execute structurally**:
|
|||||||
the handler reads the parsed structure and performs the schema change
|
the handler reads the parsed structure and performs the schema change
|
||||||
through the playground's metadata-maintaining machinery — writing
|
through the playground's metadata-maintaining machinery — writing
|
||||||
`__rdbms_playground_columns` / `__rdbms_playground_relationships`,
|
`__rdbms_playground_columns` / `__rdbms_playground_relationships`,
|
||||||
applying `STRICT`, using the ten-type vocabulary — so an
|
applying `STRICT`, using the nine-type vocabulary — so an
|
||||||
advanced-mode-created object is a first-class playground object,
|
advanced-mode-created object is a first-class playground object,
|
||||||
identical to a simple-mode-created one (ADR-0030 §5).
|
identical to a simple-mode-created one (ADR-0030 §5).
|
||||||
|
|
||||||
@@ -772,5 +772,5 @@ honouring it is not the lesson.
|
|||||||
- **ADR-0017** — the column type-change classification §7 shares.
|
- **ADR-0017** — the column type-change classification §7 shares.
|
||||||
- **ADR-0029** — column constraints; **ADR-0025** — indexes;
|
- **ADR-0029** — column constraints; **ADR-0025** — indexes;
|
||||||
**ADR-0011** — FK column-type compatibility; **ADR-0005** — the
|
**ADR-0011** — FK column-type compatibility; **ADR-0005** — the
|
||||||
ten-type vocabulary.
|
nine-type vocabulary.
|
||||||
- **ADR-0006** — undo; each DDL statement is one undo step (§10).
|
- **ADR-0006** — undo; each DDL statement is one undo step (§10).
|
||||||
|
|||||||
@@ -414,5 +414,41 @@ time-boxed-`recv` path. We therefore test the **pure pieces**
|
|||||||
exhaustively (label fn, capture state machine, nearest-deadline helper)
|
exhaustively (label fn, capture state machine, nearest-deadline helper)
|
||||||
and assert plumbing via Tier-3, rather than over-claiming an integration
|
and assert plumbing via Tier-3, rather than over-claiming an integration
|
||||||
test of the `tokio` timeout itself.
|
test of the `tokio` timeout itself.
|
||||||
</content>
|
|
||||||
</invoke>
|
## Amendment 1 — `Ctrl-G` demo-mode alias for F1 (2026-06-15)
|
||||||
|
|
||||||
|
**Context.** The contextual `hint` overlay (ADR-0053 / H2) is opened with
|
||||||
|
**F1**. But F1 reaches the app only as an escape sequence (`\eOP` /
|
||||||
|
`\e[11~`), and the `autocast` recorder used for our screencasts **cannot
|
||||||
|
emit escape sequences** — so a cast can never trigger F1, and the single
|
||||||
|
most teaching-relevant overlay is unreachable in recordings. The same
|
||||||
|
wall already bit step-captions (which is why `Ctrl+]`, a single control
|
||||||
|
byte, was chosen over `Ctrl+!`).
|
||||||
|
|
||||||
|
**Decision.** In **demo mode only**, **`Ctrl-G`** is an alias for F1. It
|
||||||
|
runs the exact F1 hint logic (live-input → form hint; empty input →
|
||||||
|
recent-error / getting-started) and is **badged as `[F1]`** (not
|
||||||
|
`[CTRL-G]`) so a recorded cast is visually identical to a genuine F1
|
||||||
|
press. `Ctrl-G` is the only viable choice: it is a single legacy control
|
||||||
|
byte autocast can send, whereas `Ctrl`+digit (e.g. the mnemonic `Ctrl-1`)
|
||||||
|
is **not encodable in a legacy terminal at all** — digits have no control
|
||||||
|
byte, so `Ctrl-1` arrives as a bare `1`; the kitty protocol *would* encode
|
||||||
|
it but only as an escape sequence (the very thing autocast can't send),
|
||||||
|
and this app deliberately does not enable keyboard-enhancement flags.
|
||||||
|
|
||||||
|
**Why demo-gated.** The shipped keymap stays F1-only — a real user never
|
||||||
|
trips the alias, and demo mode is also the mode teachers/presenters run,
|
||||||
|
so the alias is available exactly where it's wanted. Outside demo mode
|
||||||
|
`Ctrl-G` falls through to the inert catch-all (the `Char(c)` insert arm
|
||||||
|
excludes CONTROL, so no `g` is typed).
|
||||||
|
|
||||||
|
**Scope.** `hint_key` guard in `App::handle_key` gains the demo-gated
|
||||||
|
`Ctrl-G` disjunct; `demo_badge_label` maps `Ctrl-G → [F1]` (consulted
|
||||||
|
only in demo mode). Test-first: three `app.rs` Tier-1 tests (alias fires
|
||||||
|
on input + on empty input; inert when demo off) + the badge-map
|
||||||
|
assertion. 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]`.
|
||||||
|
|
||||||
|
*(Editorial: this amendment also removed two stray `</content>` /
|
||||||
|
`</invoke>` lines accidentally committed at the end of this file.)*
|
||||||
|
|||||||
@@ -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,319 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
## Amendment 1 — 2026-06-18: published live + a manual `publish` workflow
|
||||||
|
|
||||||
|
**`rdbms-playground 0.2.0` is published to crates.io** (`cargo install` and
|
||||||
|
`cargo binstall rdbms-playground` both verified working by the user). The
|
||||||
|
"unverified binstall" caveat is resolved — the per-target overrides
|
||||||
|
resolve correctly against the `v0.2.0` release assets.
|
||||||
|
|
||||||
|
**How publishing is wired:** a new **manual `workflow_dispatch` workflow**
|
||||||
|
(`.gitea/workflows/publish.yaml`), mirroring `release-macos.yaml`, takes a
|
||||||
|
`tag` input and runs `cargo publish` (token via the
|
||||||
|
`CARGO_REGISTRY_TOKEN` Gitea Actions secret — a crate-scoped,
|
||||||
|
publish-update token). **Not** automated on the tag, by decision: the
|
||||||
|
publish is irreversible (yank-only), keeping the registry token off every
|
||||||
|
tag push; the release is split (Linux/Windows on the tag, macOS
|
||||||
|
dispatched), so a human is the natural "all assets are up — go" gate; and
|
||||||
|
crates.io has no Gitea-Actions trusted-publishing path today, so a stored
|
||||||
|
token on the self-hosted runner would be the only automated option.
|
||||||
|
Each registry is its **own idempotent job** (no inter-job `needs`) — the
|
||||||
|
crates.io job skips cleanly if the version is already published (crates.io
|
||||||
|
API pre-check + `cargo publish` as the backstop) — so future
|
||||||
|
Scoop/Homebrew/winget jobs can be added alongside without breaking one
|
||||||
|
another or re-runs. The first such job's `tag`-vs-`Cargo.toml` guard
|
||||||
|
mirrors `release.yaml`.
|
||||||
|
|
||||||
|
## Amendment 2 — 2026-06-19: Scoop bucket + Homebrew tap (D3 §3b/§3c)
|
||||||
|
|
||||||
|
Two more package managers wired as **sibling `publish.yaml` jobs**
|
||||||
|
(`scoop-bucket`, `homebrew-tap`), following Amendment 1's independent +
|
||||||
|
idempotent pattern. Each fetches the release's `.sha256` sidecars, renders
|
||||||
|
a manifest, and commits it into a per-manager repo.
|
||||||
|
|
||||||
|
**Repos — org-level and multi-package.** Both live under a new **`lazyeval`
|
||||||
|
Gitea organisation** (created with the `oli` account, which gives the
|
||||||
|
`git.lazyeval.net/lazyeval/...` paths): `lazyeval/scoop-bucket` and
|
||||||
|
`lazyeval/homebrew-tap`. A Scoop *bucket* and a Homebrew *tap* are by
|
||||||
|
definition **collections of manifests**, so these are reusable for future
|
||||||
|
tools, not single-package repos. Homebrew's `homebrew-` repo-name prefix is
|
||||||
|
mandatory (→ referenced as `lazyeval/tap`); Scoop's bucket name is free.
|
||||||
|
Users: `scoop bucket add lazyeval <url>` (the label is local/arbitrary;
|
||||||
|
only the URL owner is real) then `scoop install rdbms-playground`; and
|
||||||
|
`brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap`
|
||||||
|
(the explicit-URL form — the `user/repo` shorthand assumes GitHub) then
|
||||||
|
`brew install lazyeval/tap/rdbms-playground`.
|
||||||
|
|
||||||
|
**Credential — a scoped bot user, not an `oli` PAT.** Gitea PATs scope by
|
||||||
|
**permission category, not per-repository** (`write:repository` grants
|
||||||
|
write to *every* repo the account can reach — there is no repo picker like
|
||||||
|
GitHub fine-grained PATs). So an `oli` token would also be able to push to
|
||||||
|
`oli/rdbms-playground` itself. Instead a dedicated bot user **`lazyeval-ci`**
|
||||||
|
is a member of a `lazyeval` org team with **Write** to the package repos
|
||||||
|
only; its `write:repository` PAT is therefore effectively scoped to those
|
||||||
|
repos and **cannot touch the main project repo**. Stored as the
|
||||||
|
`LAZYEVAL_PKG_TOKEN` Actions secret on `oli/rdbms-playground` (where the
|
||||||
|
workflow runs — *not* an org secret, which wouldn't reach a user-repo
|
||||||
|
workflow; *not* on the target repos, which only receive pushes). Passed via
|
||||||
|
`env:` (never inlined), so it stays masked and only materialises in the
|
||||||
|
clone URL at runtime; pushes go to `HEAD:main` (assumes the repos default
|
||||||
|
to `main`).
|
||||||
|
|
||||||
|
**Render scripts are dependency-free bash.** The CI job container is
|
||||||
|
`node:22-bookworm-slim` — **no jq, no ruby** — so
|
||||||
|
`scripts/render-{scoop-manifest,homebrew-formula}.sh` are pure bash
|
||||||
|
(heredocs, no external deps) taking a version + the relevant hashes and
|
||||||
|
emitting the manifest on stdout. `scripts/test-package-renders.sh` is their
|
||||||
|
test (JSON validated with `node` — present in the image — plus `jq`/`ruby`
|
||||||
|
when available; field-level assertions). The job validates the rendered
|
||||||
|
Scoop JSON with `node -e JSON.parse` before committing.
|
||||||
|
|
||||||
|
**Manifest specifics.**
|
||||||
|
- *Scoop* (`rdbms-playground.json` at bucket root): `64bit` =
|
||||||
|
`x86_64-pc-windows-gnu.exe`, `arm64` =
|
||||||
|
`aarch64-pc-windows-gnullvm.exe`; each URL carries a
|
||||||
|
`#/rdbms-playground.exe` rename fragment so the `bin` shim resolves
|
||||||
|
regardless of version. Carries `checkver` (lets `scoop status` / the
|
||||||
|
community excavator see lag) but **no `autoupdate`** — our pipeline is the
|
||||||
|
updater.
|
||||||
|
- *Homebrew* (`Formula/rdbms-playground.rb`): `on_macos`/`on_linux` ×
|
||||||
|
`on_arm`/`on_intel` selecting the four bare-binary assets (macOS direct;
|
||||||
|
Linux = the static `-musl` build). **Windows absent** — Homebrew has no
|
||||||
|
Windows port. `install` drops the single staged binary under a stable
|
||||||
|
name; the `test` block runs `--version`.
|
||||||
|
|
||||||
|
**Unverified (validate on first real use):** an actual `scoop install` and
|
||||||
|
`brew install`/`brew test`; the `HEAD:main` default-branch assumption; and
|
||||||
|
whether macOS Gatekeeper accepts the **ad-hoc-signed** mac binary via
|
||||||
|
`brew` (execution should be fine — ad-hoc satisfies arm64's signing
|
||||||
|
requirement and `brew`'s curl download sets no quarantine xattr, unlike a
|
||||||
|
browser download — but this rides on the still-parked Developer-ID signing
|
||||||
|
decision). **Remaining D3:** winget (komac on Linux CI, or a manual PR).
|
||||||
|
|
||||||
|
## Amendment 3 — 2026-06-19: validated end-to-end; a presigned-URL/HEAD gotcha + the Caddy fix it needs
|
||||||
|
|
||||||
|
**Scoop and Homebrew now install and run end-to-end** (`v0.2.0`,
|
||||||
|
user-verified on real Windows + macOS): `scoop install rdbms-playground`
|
||||||
|
and `brew install lazyeval/tap/rdbms-playground` both fetch, checksum,
|
||||||
|
install, and the installed binary launches. Amendment 2's "unverified"
|
||||||
|
caveats are resolved (the `HEAD:main` push populated both repos cleanly).
|
||||||
|
|
||||||
|
**Distribution depends on a server-side Caddy rule — RECORD THIS: it lives
|
||||||
|
in the Gitea edge config, NOT this repo, and if it is lost in a server
|
||||||
|
rebuild Homebrew silently 403s again.** Symptom: `brew install` failed with
|
||||||
|
`curl (22) … 403` on the asset URL. Root cause: Gitea (≥1.25; here 1.26.2)
|
||||||
|
serves release-asset downloads by **303-redirecting to a method-bound
|
||||||
|
AWS-SigV4 presigned S3 URL** (OVH), signed for the *incoming* request's HTTP
|
||||||
|
verb. Homebrew **resolves the URL with a HEAD, captures the returned
|
||||||
|
(HEAD-signed) presigned URL, then runs the download GET against that
|
||||||
|
captured URL** → GET-on-a-HEAD-signed-URL → 403. Reproduced precisely:
|
||||||
|
`GET on HEAD-resolved = 403`, `HEAD on HEAD-resolved = 200`, `GET on
|
||||||
|
GET-resolved = 200`, `HEAD on GET-resolved = 403`. GET-only tools
|
||||||
|
(`install.sh`, `install.ps1`, `cargo binstall`, curl) are unaffected — they
|
||||||
|
GET the original URL and let it redirect fresh to a GET-signed URL. This is
|
||||||
|
a **Homebrew defect** (it reuses an ephemeral, method-scoped credential as
|
||||||
|
if it were a durable resource locator); Gitea's per-request method-bound
|
||||||
|
signing is correct, and SigV4 *cannot* sign one URL for two verbs.
|
||||||
|
`SERVE_DIRECT=false` (Gitea proxies, no presign) would also fix it but was
|
||||||
|
declined — not reshaping storage for one client.
|
||||||
|
|
||||||
|
**Fix (deployed, "Tier A"): a Caddy rule that answers HEAD on
|
||||||
|
release-download paths directly** — 200, no redirect, no body — so brew's
|
||||||
|
resolve records the *original* URL (no presigned credential captured) and
|
||||||
|
its download GET runs through the redirect fresh → GET-signed → 200. Scoped
|
||||||
|
to `method HEAD` + the release-download path; **GET is untouched**, so every
|
||||||
|
working channel is unaffected, and a HEAD carries no payload so no client
|
||||||
|
can *break* (at most a HEAD-probing download manager loses a progress-bar
|
||||||
|
size — cosmetic). Reference Caddyfile (place before the Gitea
|
||||||
|
reverse_proxy):
|
||||||
|
|
||||||
|
```
|
||||||
|
@release_head {
|
||||||
|
method HEAD
|
||||||
|
path_regexp ^/[^/]+/[^/]+/releases/download/.+
|
||||||
|
}
|
||||||
|
handle @release_head {
|
||||||
|
header Accept-Ranges bytes
|
||||||
|
header Content-Type application/octet-stream
|
||||||
|
respond 200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A "Tier B" variant (a sidecar that fetches and returns the *real*
|
||||||
|
Content-Length so even HEAD-probing clients stay fully faithful) was specced
|
||||||
|
but proved unnecessary — brew is happy with the bare 200.
|
||||||
|
|
||||||
|
**macOS signing:** the brew-installed binary **runs** under the current
|
||||||
|
**ad-hoc** signature (`codesign --sign -`) — confirmed on Apple Silicon.
|
||||||
|
**Developer-ID signing + notarization remains parked** pending the user's
|
||||||
|
Apple account conversion to an Organization (GA plan / ADR-ci-003); it is
|
||||||
|
needed for *browser-download* trust, not for the package-manager paths,
|
||||||
|
which are all working now.
|
||||||
|
|
||||||
|
**Remaining D3:** winget only.
|
||||||
|
|
||||||
|
## Amendment 4 — 2026-06-20: winget (D3 §3d) — the last package manager
|
||||||
|
|
||||||
|
winget wired as the fourth `publish.yaml` sibling job, **completing the D3
|
||||||
|
package-manager set** (crates.io/binstall, Scoop, Homebrew, winget; plus the
|
||||||
|
install scripts + direct binaries).
|
||||||
|
|
||||||
|
**Model — fundamentally unlike Scoop/Homebrew.** winget has no
|
||||||
|
self-hosted-source equivalent: its default catalog is the central,
|
||||||
|
**human-gated** GitHub repo `microsoft/winget-pkgs`, and you get in by
|
||||||
|
**opening a PR** of manifests into it. So the job submits a PR (via
|
||||||
|
**komac**, pinned `2.16.0`, prebuilt glibc binary — the CI image has no
|
||||||
|
cargo) and **Microsoft's pipeline validates async** (schema, SHA256,
|
||||||
|
AV/SmartScreen scan, sandbox install) + moderator review. It is *not* live
|
||||||
|
on dispatch like the bucket/tap.
|
||||||
|
|
||||||
|
**PackageIdentifier `LazyEvaluation.RdbmsPlayground`** (publisher segment =
|
||||||
|
the publishing entity / `lazyeval` org / license holder — consistent with
|
||||||
|
everything else). Bare-exe → winget **`portable`** installer type; x64
|
||||||
|
(`-pc-windows-gnu`) + arm64 (`-pc-windows-gnullvm`), komac infers arch +
|
||||||
|
type from the binaries.
|
||||||
|
|
||||||
|
**Auth — a dedicated GitHub bot, classic token.** komac needs a **classic
|
||||||
|
`public_repo` PAT**; **fine-grained tokens cannot open the cross-fork PR**
|
||||||
|
(komac #310 — the PR is created on the *target* repo you don't administer,
|
||||||
|
which the fine-grained model can't express). A classic `public_repo` token
|
||||||
|
can't be scoped to one repo, so it lives on a **dedicated GitHub bot
|
||||||
|
account** (a leak can't reach other repos — the lazyeval-ci reasoning), as
|
||||||
|
the `WINGET_GITHUB_TOKEN` secret, referenced **only** in the `winget` job
|
||||||
|
(job-level scoping keeps it away from the crates.io / lazyeval tokens) and
|
||||||
|
passed to the API guards via a 0600 curl config file (never argv).
|
||||||
|
|
||||||
|
**Idempotency — stronger than the others need.** A re-submitted version
|
||||||
|
would open a *duplicate* PR, so before submitting the job guards on **both**
|
||||||
|
(1) already-merged versions (`contents` API on
|
||||||
|
`manifests/l/LazyEvaluation/RdbmsPlayground/<ver>`) and (2) an already-open
|
||||||
|
PR for the id+version (`search/issues`). Either → clean skip, so a repeated
|
||||||
|
`publish` dispatch is safe even mid-review.
|
||||||
|
|
||||||
|
**Signing:** none required to *submit* (only MSIX needs it; ours is
|
||||||
|
portable). The unsigned binary may earn an AV/SmartScreen **manual-review
|
||||||
|
label** on the first PR — usually clears; a nudge toward Trusted Signing
|
||||||
|
(the UK Ltd clears the 3-year-history bar) but not a blocker. Continues the
|
||||||
|
parked Developer-ID/notarization posture.
|
||||||
|
|
||||||
|
**One-time bootstrap (manual — NOT in CI, because `komac new` is
|
||||||
|
interactive).** Run once to create the package in winget-pkgs, then CI
|
||||||
|
`komac update` handles every release after:
|
||||||
|
|
||||||
|
```
|
||||||
|
komac token update # paste the bot's classic public_repo PAT at the prompt (not in argv/history)
|
||||||
|
komac new LazyEvaluation.RdbmsPlayground \
|
||||||
|
-v 0.2.0 \
|
||||||
|
-u https://git.lazyeval.net/oli/rdbms-playground/releases/download/v0.2.0/rdbms-playground-v0.2.0-x86_64-pc-windows-gnu.exe \
|
||||||
|
https://git.lazyeval.net/oli/rdbms-playground/releases/download/v0.2.0/rdbms-playground-v0.2.0-aarch64-pc-windows-gnullvm.exe \
|
||||||
|
--publisher "Lazy Evaluation Ltd" --package-name "RDBMS Playground" \
|
||||||
|
--moniker rdbms-playground --license "MIT OR Apache-2.0" \
|
||||||
|
--package-url https://relplay.org -s
|
||||||
|
```
|
||||||
|
komac downloads the two exes, detects the bare binary as **`portable`**, and
|
||||||
|
**prompts for the command alias — enter `rdbms-playground`** so users get
|
||||||
|
that on PATH (not the long versioned filename). `-s` opens the PR; fill any
|
||||||
|
remaining prompts. (Flags verified against komac 2.16.0: `-v/--version`,
|
||||||
|
`-u/--urls`, `-s/--submit`; the alias has no flag and is prompted.) Once
|
||||||
|
that first PR merges, the CI job's `komac update` takes over.
|
||||||
|
|
||||||
|
**Bootstrap learnings (2026-06-21, first real run).** GitHub bot account =
|
||||||
|
`lazyeval-ci`. Its fork **`lazyeval-ci/winget-pkgs` must exist *before*
|
||||||
|
`komac new`** — komac's auto-fork races on winget-pkgs (one of GitHub's
|
||||||
|
largest repos) and fails with *"Could not resolve to a Repository
|
||||||
|
'lazyeval-ci/winget-pkgs'"*; fork it manually (web **Fork**, or `gh repo
|
||||||
|
fork microsoft/winget-pkgs --clone=false`), wait for it to populate, then
|
||||||
|
re-run. **Alias:** for a **direct single-exe portable**, winget derives the
|
||||||
|
on-PATH command from **`Commands[0]`** (the interactive "Commands" prompt →
|
||||||
|
`rdbms-playground`) — **not** `PortableCommandAlias`, which is
|
||||||
|
**archive/nested-portable-only** and must NOT be added to our manifest
|
||||||
|
(verified against winget-cli's portable-install logic + the 1.6.0 installer
|
||||||
|
schema; komac correctly omits it). So komac's generated manifest needs no
|
||||||
|
hand-editing. First PR: **#391335**.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# ADR-0057: Non-functional-requirement verification strategy
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted (2026-06-22).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The non-functional requirements (`requirements.md` NFR-1..7 — startup,
|
||||||
|
input latency, memory, distinctive design, colour use, cross-platform
|
||||||
|
parity, light/dark legibility) were quality bars that had **never been
|
||||||
|
formally verified**, even though v0.2.0 ships public binaries. With users
|
||||||
|
now pulling updates, we want each NFR either **gated by an automated test**
|
||||||
|
(so a regression fails CI) or **measured-and-documented** with a recorded
|
||||||
|
figure and a reviewer judgement — not left as an aspiration.
|
||||||
|
|
||||||
|
The decision below was taken with the user: contrast is a hard gate;
|
||||||
|
startup and memory are measured against generous bounds (not the literal
|
||||||
|
target — a tight timing/RSS gate would flake on a shared runner); the
|
||||||
|
qualitative bars are verified by argument and reviewer note.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### NFR-5 / NFR-7 — colour contrast & legibility: **gated**
|
||||||
|
|
||||||
|
Unit tests in `src/theme.rs` (Tier 1, run by the existing gate):
|
||||||
|
|
||||||
|
- **`all_text_colours_meet_wcag_aa_contrast`** — every text-bearing
|
||||||
|
foreground (`fg`, `muted`, mode labels, `system`/`error`/`warning`,
|
||||||
|
`plan_efficient`, and all `tok_*`) must clear **WCAG-AA 4.5:1** against
|
||||||
|
`bg`, in **both** themes.
|
||||||
|
- **`advanced_mode_border_meets_ui_contrast`** — the advanced-mode border
|
||||||
|
carries a mode-warning signal, so it meets the **3:1** non-text /
|
||||||
|
UI-component threshold. The plain `border` is decorative structural
|
||||||
|
chrome and is **deliberately exempt** (it sits at 2.20:1 dark / 1.82:1
|
||||||
|
light by design, so panel borders recede behind content — recorded here
|
||||||
|
rather than silently tolerated).
|
||||||
|
- **`syntax_token_colours_are_perceptually_distinct`** — contrast against
|
||||||
|
the background is necessary but not sufficient: two token colours can
|
||||||
|
each clear 4.5:1 yet be hard to tell *apart* on a good screen. Every pair
|
||||||
|
of syntax-token classes that can share a line must clear **CIEDE2000
|
||||||
|
(ΔE2000) ≥ 15** (post-tuning minimum is ~18). The metric is itself
|
||||||
|
validated against the Sharma et al. reference vectors
|
||||||
|
(`delta_e_2000_matches_reference_vectors`).
|
||||||
|
|
||||||
|
Computing the real ratios while writing these tests **uncovered two
|
||||||
|
shipped defects** in the v0.2.0 light theme — `tok_string` at 4.42:1 and
|
||||||
|
`tok_flag` at 3.15:1, both below the 4.5:1 the scheme promises — plus two
|
||||||
|
dark-theme token pairs that were perceptually close despite distinct hex
|
||||||
|
(`tok_type`↔`tok_keyword`, `tok_function`↔`tok_identifier`, ΔE2000 ~14).
|
||||||
|
All four were fixed in the same change (commit `65eab71`); the gates now
|
||||||
|
hold the palette to both bars permanently. A dev tool,
|
||||||
|
`scripts/palette-preview.py`, renders the live palette with contrast +
|
||||||
|
ΔE2000 for future palette work.
|
||||||
|
|
||||||
|
### NFR-1 / NFR-3 — startup & memory: **measured, generously bounded**
|
||||||
|
|
||||||
|
The Tier-4 PTY harness (ADR-0008 Amendment 1) measures both:
|
||||||
|
|
||||||
|
- **`startup_to_first_frame_is_reasonable`** — spawn → first rendered frame.
|
||||||
|
- **`idle_memory_footprint_is_reasonable`** — child `/proc/<pid>` VmRSS
|
||||||
|
after the app is idle (Linux only).
|
||||||
|
|
||||||
|
These run against the **debug** binary, so they assert *generous* bounds
|
||||||
|
(startup < 2000 ms, RSS < 150 MB) for **gross-regression detection**, not
|
||||||
|
the literal NFR targets — a tight 500 ms / 50 MB gate on a debug binary
|
||||||
|
under a loaded shared runner would flake. The **real release figures**
|
||||||
|
(measured 2026-06-22, 5 trials each, this dev machine) are the documented
|
||||||
|
verification:
|
||||||
|
|
||||||
|
| NFR | Target | Release median | Margin |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| NFR-1 startup → first frame | < 500 ms | **~29 ms** | ~17× under |
|
||||||
|
| NFR-3 idle RSS | < 50 MB | **~10 MB** | ~5× under |
|
||||||
|
|
||||||
|
(Measurement granularity is the harness poll interval, ~20 ms — figures
|
||||||
|
are "tens of ms", not sub-ms precise.) Per the user decision these are
|
||||||
|
**not** wired as tight CI gates; the generous debug-bound tests are the
|
||||||
|
ongoing regression signal.
|
||||||
|
|
||||||
|
### NFR-2 — input responsiveness: **verified by architecture**
|
||||||
|
|
||||||
|
"Long-running queries execute off the UI thread so the interface stays
|
||||||
|
responsive." This is structural and already true: database work runs on a
|
||||||
|
dedicated **worker thread** (ADR-0010), and terminal input is read on a
|
||||||
|
**separate Tokio task** (`spawn_event_reader` in `src/runtime.rs`), so a
|
||||||
|
running query cannot block keystroke handling or rendering. There is no
|
||||||
|
automated keystroke-latency test — a deterministic sub-16 ms measurement
|
||||||
|
in a PTY harness would be flaky and low-value — so NFR-2 is verified by
|
||||||
|
this architectural argument rather than a gate.
|
||||||
|
|
||||||
|
### NFR-4 / NFR-6 — distinctive design & cross-platform parity: **reviewer note**
|
||||||
|
|
||||||
|
Qualitative bars. **NFR-4** (deliberate, identifiable palette/layout using
|
||||||
|
box-drawing) is satisfied by the bespoke two-theme palette, the
|
||||||
|
three-region framed layout, and the annotated plan/relationship rendering —
|
||||||
|
reviewer judgement, not automatable. **NFR-6** is partly evidenced by the
|
||||||
|
CI matrix (Linux + macOS **execute** the suite; Windows is **build-only**,
|
||||||
|
no runner) and is otherwise a reviewer judgement; one documented divergence:
|
||||||
|
on terminals/multiplexers without true-colour passthrough (e.g. a tmux
|
||||||
|
session missing `Tc`/`RGB`), the 24-bit palette is quantised to the
|
||||||
|
256-colour cube and near hues can collide — a terminal-capability issue,
|
||||||
|
not a palette one.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Contrast and perceptual distinctness are now **permanently gated** — the
|
||||||
|
palette cannot regress below WCAG-AA or into look-alike token colours
|
||||||
|
without failing the build.
|
||||||
|
- Startup and memory have **recorded figures** and a generous
|
||||||
|
gross-regression test; the literal targets are met with large margins.
|
||||||
|
- The qualitative and architectural NFRs are **documented with evidence**
|
||||||
|
rather than asserted.
|
||||||
|
- A latent behaviour found during this work — fast DDL→insert misparsing
|
||||||
|
against a stale schema cache — is tracked as **issue #39** (deferred; no
|
||||||
|
interactive-user impact).
|
||||||
|
|
||||||
|
## Requirements bookkeeping
|
||||||
|
|
||||||
|
NFR-1, NFR-3, NFR-5, NFR-7 → `[x]` (gated test and/or measured with
|
||||||
|
evidence). NFR-2 → `[x]` (architectural). NFR-4, NFR-6 → `[/]` (reviewer
|
||||||
|
note; not fully automatable).
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
# ADR-0058: Clause-concept hints — a `hint.concept.*` layer surfaced by cursor position (issue #37)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted — **implemented 2026-06-23** on branch `feat/clause-concept-hints`
|
||||||
|
(pending merge). Number **0058**, reserved up front via the reserve-first
|
||||||
|
flow (ADR-0059) rather than the older placeholder-until-merge default —
|
||||||
|
this ADR is the first to carry a number stable from creation. Extends
|
||||||
|
**ADR-0053** (contextual `hint`, D3/D4) additively; does not supersede it.
|
||||||
|
Closes the deferred extension tracked as Gitea issue **#37**.
|
||||||
|
|
||||||
|
Implementation: `Node::Concept { topic, inner }` (`dsl/grammar/mod.rs`) +
|
||||||
|
the span-recording driver arm (`dsl/walker/driver.rs`) +
|
||||||
|
`WalkContext::concept_spans` / `ConceptSpan` (`dsl/walker/context.rs`);
|
||||||
|
the `concept_topic_at_cursor` resolver (`dsl/walker/mod.rs`); 10
|
||||||
|
`Node::Concept` wrappers across `shared.rs` / `ddl.rs` /
|
||||||
|
`sql_create_table.rs` (one shared `REFERENTIAL_CLAUSES`, two extracted
|
||||||
|
cardinality sub-`Seq`s); the mode-keyed `emit_tier3_block` +
|
||||||
|
`note_hint_for_input` layering (`app.rs`); the seven `hint.concept.*`
|
||||||
|
blocks + `hint.block.concept_heading` (`strings/en-US.yaml`) with
|
||||||
|
`keys.rs` declarations; and the test set (14 resolver unit tests, 3
|
||||||
|
comprehensiveness gates incl. the recursive `Node::Concept` visitor, 3 F1
|
||||||
|
integration tests, the `hint_block_with_concept` snapshot). Suite green
|
||||||
|
across all tiers; `fmt`/`clippy` clean.
|
||||||
|
|
||||||
|
Revised after a `/runda` review (2026-06-22) that grounded the mechanism
|
||||||
|
against the code and caught two design gaps: the clause mapping (D4)
|
||||||
|
covered only the advanced-SQL constraint grammar and missed the
|
||||||
|
simple-mode constraint suffix (ADR-0029); and the single-`example`
|
||||||
|
content model collided with ADR-0053 D6's mode-correct-example rule for
|
||||||
|
topics reachable in both modes. Both are resolved here — D4 now maps both
|
||||||
|
grammars, and D5 adopts **mode-keyed examples** (user decision,
|
||||||
|
2026-06-22).
|
||||||
|
|
||||||
|
References ADR-0053 (the three-tier `hint` model + the `hint.cmd.*` /
|
||||||
|
`hint.err.*` corpus), ADR-0024 (the unified grammar/walker, `HintMode`-
|
||||||
|
per-node, `MatchedPath` spans), ADR-0022 (ambient tier-2 typing
|
||||||
|
assistance), ADR-0013/0045 (the relationship + `m:n` grammar), ADR-0035
|
||||||
|
(advanced-mode `CREATE TABLE` constraint grammar).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0053 delivers tier-3 teaching hints at **per-form** granularity: one
|
||||||
|
`hint.cmd.<form>` block (`what` / `example` / `concept`) per command form,
|
||||||
|
surfaced by F1 on the live input. Position-awareness *within* a form is
|
||||||
|
owned by tier-2 (the ambient candidate list / slot prose, ADR-0022).
|
||||||
|
|
||||||
|
During ADR-0053 Phase B we identified a third teaching point the per-form
|
||||||
|
model does not serve: **concepts that live inside a clause**, where a
|
||||||
|
learner may want teaching deeper than tier-2's candidate list but narrower
|
||||||
|
than the whole-form block. The canonical examples are the most
|
||||||
|
load-bearing relational concepts in the tool:
|
||||||
|
|
||||||
|
- the **referential actions** clause — `… on delete ⟨cascade | set null |
|
||||||
|
restrict | no action⟩` (and `on update`);
|
||||||
|
- the **cardinality** marker — `1:n` (one parent, many children) and
|
||||||
|
`m:n` (many-to-many, auto-junction per ADR-0045);
|
||||||
|
- the **primary-key** declaration — `with pk(…)` in simple mode and the
|
||||||
|
`primary key` constraint in `create table (…)`;
|
||||||
|
- the **column-constraint slots** in `create table (…)` — `unique`,
|
||||||
|
`check (…)`, `foreign key` / `references`.
|
||||||
|
|
||||||
|
A student parked in one of these clauses is, by definition, looking at the
|
||||||
|
exact relational concept that clause embodies. ADR-0053 explicitly
|
||||||
|
deferred this ("clause-concept hints", issue #37) and recorded that the
|
||||||
|
per-form keying does **not** lock it out: a later `hint.concept.<topic>`
|
||||||
|
namespace can be surfaced when the cursor sits in a recognized clause,
|
||||||
|
layered on top of the per-form block.
|
||||||
|
|
||||||
|
This ADR is that follow-up. It is **pedagogy-led, not metrics-led** — we
|
||||||
|
do not expect to accumulate usage statistics about which clauses learners
|
||||||
|
pause at, so the clause set is chosen by relational-teaching value, not
|
||||||
|
data.
|
||||||
|
|
||||||
|
### Two user decisions taken up front (2026-06-22)
|
||||||
|
|
||||||
|
The issue left two scope questions open. Both were decided by the user
|
||||||
|
before design:
|
||||||
|
|
||||||
|
1. **Detection precision: "anywhere inside the clause."** The hint fires
|
||||||
|
whenever the cursor sits *within* a recognized clause's span —
|
||||||
|
including parked inside already-typed text (e.g. `add 1:n
|
||||||
|
relation|ship …`) — not only at the slot boundary where the next token
|
||||||
|
is about to be typed.
|
||||||
|
2. **Clause scope: all four families** — referential actions, the
|
||||||
|
create-table constraint slots, `with pk`, and `1:n` / `m:n`
|
||||||
|
cardinality.
|
||||||
|
|
||||||
|
### Why the existing `HintMode`/`pending_hint_mode` mechanism is not enough
|
||||||
|
|
||||||
|
Tier-2 slot prose is driven by `Node::Hinted { mode, inner }`: entering
|
||||||
|
the node sets `ctx.pending_hint_mode`, and the resolver reads it at the
|
||||||
|
end of the cursor-trimmed walk (`hint_resolution_at_input_in_mode`,
|
||||||
|
`src/dsl/walker/mod.rs:107`). Crucially, the driver **clears
|
||||||
|
`pending_hint_mode` on every successful match** (`src/dsl/walker/
|
||||||
|
driver.rs:154`): it records only "the Hinted slot the cursor is *about to
|
||||||
|
fill*", and the moment the slot's content matches, the signal is gone.
|
||||||
|
|
||||||
|
That is exactly the *boundary* semantics tier-2 wants — and exactly the
|
||||||
|
opposite of "anywhere inside the clause". The instant the learner finishes
|
||||||
|
typing `cascade`, `pending_hint_mode` clears, so a cursor parked on the
|
||||||
|
finished `on delete cascade` would see nothing. Decision (1) above
|
||||||
|
therefore needs a **span-based** mechanism that survives the clause being
|
||||||
|
fully matched, not the single-slot `pending_hint_mode`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### D1 — A span-recording grammar wrapper, `Node::Concept`
|
||||||
|
|
||||||
|
Add a new grammar combinator, sibling to `Node::Hinted`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Node::Concept { topic: &'static str, inner: &'static Node }
|
||||||
|
```
|
||||||
|
|
||||||
|
It is a pass-through for matching (it walks `inner` and returns `inner`'s
|
||||||
|
result verbatim — it never changes what parses), but as a side effect the
|
||||||
|
driver records the **byte span the node covered** into a new context
|
||||||
|
field:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// WalkContext
|
||||||
|
pub concept_spans: Vec<ConceptSpan>, // { topic: &'static str, start: usize, end: usize }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `start` = the first non-whitespace byte the node began matching at
|
||||||
|
(post `skip_whitespace`, matching the driver's existing convention).
|
||||||
|
- `end` = the byte position the node's walk reached — the end of the
|
||||||
|
matched span on a full match, or the stop position on an incomplete /
|
||||||
|
failed inner. This makes the span the real extent of the clause as typed
|
||||||
|
so far.
|
||||||
|
|
||||||
|
Unlike `pending_hint_mode`, `concept_spans` is **append-only across the
|
||||||
|
whole walk** and is never cleared on match — so a fully-typed clause keeps
|
||||||
|
its span, satisfying decision (1).
|
||||||
|
|
||||||
|
**Precise push rule** (grounded in `NodeWalkResult`,
|
||||||
|
`src/dsl/walker/driver.rs:97`): walk `inner`, then push `{ topic, start,
|
||||||
|
end }` iff the inner result is one of `Matched { end }` (end = `end`),
|
||||||
|
`Incomplete { position }`, or `Failed { position }` (end = `position`) —
|
||||||
|
i.e. the node **committed** (consumed at least its first token, or ran out
|
||||||
|
mid-clause). On `NoMatch { .. }` (the node never engaged — e.g. a `Choice`
|
||||||
|
tried this branch and it didn't apply) push **nothing**. This is the
|
||||||
|
`NodeWalkResult`-level analogue of the driver's existing
|
||||||
|
`pending_hint_mode` clear-on-match leak-avoidance, and it means a failed
|
||||||
|
`Choice` branch leaves no stale span.
|
||||||
|
|
||||||
|
`Node::Concept` is **orthogonal to `Node::Hinted`** — a clause that wants
|
||||||
|
both a tier-2 slot prose *and* a tier-3 concept span nests them (`Concept{
|
||||||
|
… inner: Hinted{ … } }`). Neither changes the parse.
|
||||||
|
|
||||||
|
### D2 — Cursor → topic resolution: innermost containing span
|
||||||
|
|
||||||
|
A new resolver:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn concept_topic_at_cursor(
|
||||||
|
input: &str, cursor: usize,
|
||||||
|
schema: Option<&SchemaCache>, mode: Mode,
|
||||||
|
) -> Option<&'static str>
|
||||||
|
```
|
||||||
|
|
||||||
|
walks the **full** input buffer with `WalkBound::EndOfInput` (the dormant
|
||||||
|
`WalkBound::Position` path is *not* used — see D3 rationale), collects
|
||||||
|
`ctx.concept_spans`, and returns the `topic` of the **innermost span
|
||||||
|
containing `cursor`**. "Innermost" = the containing span with the latest
|
||||||
|
`start` (equivalently, the narrowest), so when a `references` clause
|
||||||
|
(constraint slot) wraps a referential-actions clause, a cursor on `on
|
||||||
|
delete` resolves to the more specific `referential_actions`, while a
|
||||||
|
cursor on `references Parent` resolves to `foreign_key`.
|
||||||
|
|
||||||
|
Containment is inclusive of `start` and `end` (`start <= cursor <= end`),
|
||||||
|
so the boundary case — cursor *at* the point of entering the clause —
|
||||||
|
also resolves, meaning this subsumes the simpler boundary detection for
|
||||||
|
free.
|
||||||
|
|
||||||
|
The `cursor` passed in is the one **already computed by `feedback_view()`**
|
||||||
|
(`src/app.rs:3204` returns `(view, cursor, _off)`), which strips the `:`
|
||||||
|
one-shot sigil and adjusts the cursor offset to index into the stripped
|
||||||
|
`view`. The concept resolver walks that same `view`, so the `:`-strip is
|
||||||
|
handled with no extra offset arithmetic.
|
||||||
|
|
||||||
|
### D3 — Why full-buffer span-containment, not `WalkBound::Position`
|
||||||
|
|
||||||
|
`WalkBound::Position(cursor)` (defined, `#[allow(dead_code)]`,
|
||||||
|
`src/dsl/walker/outcome.rs:26`) slices the source at the cursor and walks
|
||||||
|
the prefix. Two reasons it is the wrong tool here:
|
||||||
|
|
||||||
|
- **It discards text after the cursor.** A cursor parked early in a
|
||||||
|
complete command (`add 1:n| relationship from … on delete cascade`)
|
||||||
|
would truncate to `add 1:` and lose the parsed structure that tells us
|
||||||
|
we are inside a well-formed relationship command. Full-buffer walking
|
||||||
|
keeps the whole parse; the cursor is used *only* for span containment.
|
||||||
|
- **It still rides `pending_hint_mode`'s clear-on-match**, which D1
|
||||||
|
already establishes is unsuitable for inside-clause detection.
|
||||||
|
|
||||||
|
Span-recording over a full `EndOfInput` walk needs no new walk-bound
|
||||||
|
behaviour and leaves the dormant `Position` path untouched.
|
||||||
|
|
||||||
|
### D4 — The clause set and their topics (both grammars)
|
||||||
|
|
||||||
|
**Seven** `hint.concept.<topic>` blocks, placed via `Node::Concept`
|
||||||
|
wrappers across **both** the simple-mode (`grammar/ddl.rs`, ADR-0029) and
|
||||||
|
advanced-mode (`grammar/sql_create_table.rs`, ADR-0035) grammars. The
|
||||||
|
codebase has **two distinct constraint grammars** — simple-mode `create
|
||||||
|
table` column constraints (`COLUMN_CONSTRAINT_SUFFIX`, `ddl.rs:1082`:
|
||||||
|
`not null` / `unique` / `default` / `check`; PK is `with pk`, FK is a
|
||||||
|
relationship) and the advanced-SQL constraint set (`COL_CONSTRAINT_CHOICES`,
|
||||||
|
`sql_create_table.rs`: adds `primary key` / `references`). A topic
|
||||||
|
reachable in both modes is wrapped in **both** grammars (or once, if the
|
||||||
|
node is physically shared — e.g. `REFERENTIAL_CLAUSES` in `shared.rs` is
|
||||||
|
reused by the relationship command *and* the advanced `references` clause).
|
||||||
|
|
||||||
|
| Topic | Modes | Grammar node(s) to wrap |
|
||||||
|
|---|---|---|
|
||||||
|
| `referential_actions` | both | `REFERENTIAL_CLAUSES` (`shared.rs` — one wrapper, reused in both) |
|
||||||
|
| `cardinality_one_to_many` | simple only | a new `CARDINALITY_1N` sub-`Seq` extracted from `ADD_RELATIONSHIP_NODES` (`ddl.rs:479`) |
|
||||||
|
| `cardinality_many_to_many` | simple only | a new `CARDINALITY_MN` sub-`Seq` extracted from `CREATE_M2N` head (`ddl.rs`) |
|
||||||
|
| `primary_key` | both | `WITH_PK` (`ddl.rs`, simple) + the `primary key` constraint node (`sql_create_table.rs`, advanced) |
|
||||||
|
| `unique` | both | the `unique` constraint node in `ddl.rs` (`UNIQUE_CONSTRAINT`, `:1051`) + in `sql_create_table.rs` |
|
||||||
|
| `check` | both | the `check (…)` constraint node in `ddl.rs` (`:1064`) + in `sql_create_table.rs` |
|
||||||
|
| `foreign_key` | advanced only | `REFERENCES_CLAUSE` (`sql_create_table.rs:206`) |
|
||||||
|
|
||||||
|
If a `unique` / `check` node turns out to be physically shared between the
|
||||||
|
two grammars (as `REFERENTIAL_CLAUSES` is), one wrapper suffices — the
|
||||||
|
mode-keyed example (D5) is selected by the **live mode** at F1 time, not by
|
||||||
|
which grammar node matched, so wrapper-sharing never affects correctness.
|
||||||
|
The exact wrapper count is pinned during implementation; the *topic* set is
|
||||||
|
the seven above.
|
||||||
|
|
||||||
|
**Nesting is intentional.** `foreign_key`'s `REFERENCES_CLAUSE` contains
|
||||||
|
`referential_actions`' `REFERENTIAL_CLAUSES`, so a cursor on `references
|
||||||
|
Parent` resolves to `foreign_key` and a cursor on the inner `on delete`
|
||||||
|
resolves to `referential_actions` — D2's innermost-wins handles it.
|
||||||
|
|
||||||
|
**Deliberate omissions** (faithful to issue #37's explicit list of
|
||||||
|
"primary key | unique | check | foreign key"): the **`not null`** and
|
||||||
|
**`default`** column constraints get **no** concept block in v1. They are
|
||||||
|
mechanical rather than the headline relational concepts #37 targets;
|
||||||
|
adding them later is purely additive (a wrapper + a block + a coverage
|
||||||
|
entry).
|
||||||
|
|
||||||
|
Cardinality is **split** (`_one_to_many` vs `_many_to_many`) rather than a
|
||||||
|
single "cardinality" block: the two are pedagogically distinct — `m:n`
|
||||||
|
auto-creates a junction table (ADR-0045) — the cursor is always
|
||||||
|
unambiguously in one or the other, and both are **simple-mode-only**
|
||||||
|
(`1:n`/`m:n` are app syntax, not SQL), so each carries a single plain
|
||||||
|
example with no mode conflict.
|
||||||
|
|
||||||
|
`referential_actions` is one block covering both `on delete` and `on
|
||||||
|
update` and all four actions (the action set is the concept; the block
|
||||||
|
explains the choice rather than each action in isolation).
|
||||||
|
|
||||||
|
### D5 — The content model: `what` / `example` / `concept`, with mode-keyed examples
|
||||||
|
|
||||||
|
Each `hint.concept.<topic>` block keeps the ADR-0053 D3 three-part shape —
|
||||||
|
`what` / `example` / `concept`. The one extension is the **example**: to
|
||||||
|
honour ADR-0053 D6's "mode-correct example, no cross-mode sharing" rule
|
||||||
|
for topics reachable in both modes (the `/runda` finding), a both-mode
|
||||||
|
topic carries **two** examples keyed by mode; a single-mode topic carries
|
||||||
|
one plain example.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# both-mode topic — mode-keyed example
|
||||||
|
hint.concept.referential_actions:
|
||||||
|
what: "Decide what happens to child rows when the parent they point at is deleted, or its key changes."
|
||||||
|
example:
|
||||||
|
simple: "add 1:n relationship from Customers.id to Orders.customer_id on delete cascade"
|
||||||
|
advanced: "create table Orders (id int primary key, customer_id int references Customers(id) on delete cascade)"
|
||||||
|
concept: "A foreign key forbids orphans by default (restrict). `cascade`
|
||||||
|
deletes the children with the parent; `set null` keeps them but clears
|
||||||
|
the link. The action encodes a real rule about your data."
|
||||||
|
|
||||||
|
# single-mode topic — one plain example
|
||||||
|
hint.concept.cardinality_one_to_many:
|
||||||
|
what: "Link two tables so one parent row can own many child rows."
|
||||||
|
example: "add 1:n relationship from Customers.id to Orders.customer_id"
|
||||||
|
concept: "‘1:n’ is one parent, many children. The child table holds the
|
||||||
|
foreign key column pointing back at the parent — that single shared
|
||||||
|
value is what groups a parent's children together."
|
||||||
|
```
|
||||||
|
|
||||||
|
`what`/`example` are always present; `concept` is the teaching payload
|
||||||
|
(it is what makes this tier-3). The both-mode topics are
|
||||||
|
`referential_actions`, `primary_key`, `unique`, `check`; the single-mode
|
||||||
|
(simple-only) topics are `cardinality_one_to_many`,
|
||||||
|
`cardinality_many_to_many`; `foreign_key` is single-mode (advanced-only).
|
||||||
|
|
||||||
|
The blocks live under the existing `hint:` namespace in
|
||||||
|
`src/friendly/strings/en-US.yaml`, alongside `hint.cmd.*` / `hint.err.*`,
|
||||||
|
and every leaf key is declared in
|
||||||
|
`src/friendly/keys.rs::KEYS_AND_PLACEHOLDERS` (the validator requires
|
||||||
|
exhaustive declaration — no prefix exemption for the `hint.*` families).
|
||||||
|
For both-mode topics that means `hint.concept.<topic>.example.simple` +
|
||||||
|
`.example.advanced`; for single-mode topics, `hint.concept.<topic>.example`.
|
||||||
|
|
||||||
|
**Authoring constraints** (project copy rules — see `CLAUDE.md`): concept
|
||||||
|
text is user-facing, so it must **never name the database engine** and
|
||||||
|
must **not say "DSL"** — use "simple mode" / "advanced mode". The `concept`
|
||||||
|
prose teaches the relational idea in plain language; examples are
|
||||||
|
copyable, mode-correct lines.
|
||||||
|
|
||||||
|
### D6 — Rendering: the concept block layers under the form block
|
||||||
|
|
||||||
|
This is an **additive** F1 surface. When F1 fires on live input
|
||||||
|
(`note_hint_for_input`, `src/app.rs:3200`):
|
||||||
|
|
||||||
|
1. Resolve and emit the per-form `hint.cmd.<form>` block exactly as today
|
||||||
|
(unchanged).
|
||||||
|
2. **Then** call `concept_topic_at_cursor(buffer, cursor, …)`. If it
|
||||||
|
yields a topic and `hint.concept.<topic>.what` exists, emit a second
|
||||||
|
tier-3 block beneath the form block.
|
||||||
|
|
||||||
|
The two blocks are visually distinguished by their **heading**: the form
|
||||||
|
block keeps the existing `hint.block.heading` ("Hint"); the concept block
|
||||||
|
carries a distinct sub-heading from a new `hint.block.concept_heading`
|
||||||
|
(e.g. "About this clause") so the learner sees the form-level help *and*
|
||||||
|
the clause-level concept without confusing the two.
|
||||||
|
|
||||||
|
`emit_tier3_block` gains **two** small parameters: the heading key, and an
|
||||||
|
optional mode used to resolve the example. Its example lookup becomes:
|
||||||
|
prefer `<stem>.example.<mode>` (where `<mode>` is `simple`/`advanced` from
|
||||||
|
the live `effective_mode`), else fall back to a plain `<stem>.example`.
|
||||||
|
This keeps a single renderer for all three tier-3 families — `hint.cmd.*`
|
||||||
|
and `hint.err.*` keep a plain `.example` (lookup falls straight through),
|
||||||
|
while `hint.concept.*` both-mode topics resolve the mode-keyed variant.
|
||||||
|
The mode-correct-example contract of ADR-0053 D6 is thereby honoured by
|
||||||
|
the *content keying* + this lookup, not by duplicating the renderer.
|
||||||
|
|
||||||
|
The concept block is **never shown alone** and **never replaces** the form
|
||||||
|
block — consistent with the issue ("layered on top of … does not
|
||||||
|
replace"). If the per-form resolution fails (rare; falls back to tier-2),
|
||||||
|
the concept layer is skipped too — F1 keeps a single coherent behaviour.
|
||||||
|
|
||||||
|
The submitted `hint` command (last-error route, D2 of ADR-0053) is
|
||||||
|
**unchanged** — it has no cursor, so concept hints do not apply there.
|
||||||
|
|
||||||
|
The rendered shape of a form-block-plus-concept-block is locked by a new
|
||||||
|
`insta` snapshot (`hint_block_with_concept`).
|
||||||
|
|
||||||
|
### D7 — Comprehensiveness, validation, fallback
|
||||||
|
|
||||||
|
- **Validation (build/test gate):** every `hint.concept.<topic>` leaf key
|
||||||
|
is declared in `keys.rs`; the existing `keys_validate_against_catalog`
|
||||||
|
test fails on a typo or an undeclared key, in either direction.
|
||||||
|
- **Comprehensiveness coverage test:** extend the ADR-0053
|
||||||
|
comprehensiveness test with a `CONCEPT_TOPICS` table — each entry a
|
||||||
|
`(topic, ModeCoverage)` where `ModeCoverage` is `Both` or
|
||||||
|
`Single` — asserting: (a) every topic resolves to `hint.concept.<topic>`
|
||||||
|
with a `.what` (and, per its `ModeCoverage`, either `.example.simple`
|
||||||
|
+`.example.advanced` for `Both`, or a plain `.example` for `Single`);
|
||||||
|
and (b) every `Node::Concept` wrapper reachable from the REGISTRY
|
||||||
|
carries a topic in the table. (b) needs a **recursive `Node`-tree
|
||||||
|
visitor** — `Node` is a recursive enum (`Seq(&[Node])` / `Choice` /
|
||||||
|
`Optional` / `Repeated` / `Hinted` / `Concept`), so the test walks each
|
||||||
|
REGISTRY shape collecting `Node::Concept` topics. A wrapper added
|
||||||
|
without content (or a topic with a wrapper missing) fails the test —
|
||||||
|
this is what makes the clause set enforceable rather than aspirational,
|
||||||
|
since `keys.rs` only checks that *referenced* keys resolve.
|
||||||
|
- **Graceful degrade:** `emit_tier3_block` already returns `false` when a
|
||||||
|
`.what` key is absent, so a missing concept block degrades to "form
|
||||||
|
block only" — never a blank or an error. The validation above means
|
||||||
|
that path is a safety net, not the plan.
|
||||||
|
|
||||||
|
## Forks
|
||||||
|
|
||||||
|
- **Detection precision = anywhere inside the clause** (user, 2026-06-22)
|
||||||
|
rather than slot-boundary-only. Drives the span-recording mechanism
|
||||||
|
(D1) over the simpler `pending_hint_mode` reuse.
|
||||||
|
- **Clause scope = all four families** (user, 2026-06-22) rather than the
|
||||||
|
issue's "start with referential actions + constraint slots" subset.
|
||||||
|
- **Mechanism = full-buffer span-containment, not `WalkBound::Position`**
|
||||||
|
(D3) — keeps the whole parse and leaves the dormant cursor-bound path
|
||||||
|
untouched. *Engineering choice; rationale in D3.*
|
||||||
|
- **Mode-keyed examples for both-mode topics** (user, 2026-06-22,
|
||||||
|
resolving the `/runda` finding) rather than splitting into
|
||||||
|
`_simple`/`_advanced` blocks — one concept block per topic, two examples
|
||||||
|
selected by live mode (D5/D6). Honours ADR-0053 D6's mode-correct-example
|
||||||
|
rule without duplicating concept prose.
|
||||||
|
- **Cardinality split into two blocks** (D4) rather than one — the `1:n`
|
||||||
|
and `m:n` concepts diverge (junction table). *Pedagogy choice.*
|
||||||
|
- **`primary_key` shared between `with pk` and the constraint slot** (D4)
|
||||||
|
— one concept, two syntaxes (a mode-keyed example per D5). *Content
|
||||||
|
choice.*
|
||||||
|
- **Distinct sub-heading for the concept block** (D6) rather than merging
|
||||||
|
into one block or an undifferentiated second "Hint" — keeps form-level
|
||||||
|
and clause-level guidance legible. *UX choice.*
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **A fourth contextual surface exists** within tier-3: clause-level
|
||||||
|
teaching, deeper than tier-2's candidate list, narrower than the
|
||||||
|
per-form block, surfaced by cursor position on F1.
|
||||||
|
- **One new grammar node** (`Node::Concept`), **one new `WalkContext`
|
||||||
|
field** (`concept_spans`), **one new resolver**
|
||||||
|
(`concept_topic_at_cursor`), **two new renderer parameters** (heading
|
||||||
|
key + example mode), and **~10 `Node::Concept` wrappers** across both
|
||||||
|
constraint grammars (exact count pinned at implementation per D4's
|
||||||
|
shared-node note), plus **two extracted cardinality sub-`Seq` nodes**.
|
||||||
|
No change to what parses; no change to tier-2; no change to the
|
||||||
|
submitted `hint` path.
|
||||||
|
- **A new content sub-namespace** — `hint.concept.*` (seven topics; the
|
||||||
|
four both-mode topics carry two examples each) — enters the catalogue
|
||||||
|
under `hint:`, validated by `keys.rs` and the comprehensiveness test.
|
||||||
|
Ongoing surface: a future clause worth teaching ships its
|
||||||
|
`Node::Concept` wrapper + block + coverage entry together (a checklist
|
||||||
|
item for feature ADRs that add clauses).
|
||||||
|
- **CHANGELOG:** a new `[Unreleased] → Added` bullet (user-facing F1
|
||||||
|
behaviour), phrased under the copy rules (no engine name; "simple
|
||||||
|
mode"/"advanced mode", not "DSL").
|
||||||
|
- **Testing:** Tier-1 unit tests for `concept_topic_at_cursor` across each
|
||||||
|
clause (cursor at boundary, cursor inside typed text, cursor outside →
|
||||||
|
`None`, nested `references`→`on delete` innermost-wins, both modes);
|
||||||
|
the F1 layering logic (form block always; concept block appended iff a
|
||||||
|
topic resolves; concept skipped when the form block falls back); the
|
||||||
|
`:` one-shot strip carried through (it must still resolve advanced
|
||||||
|
forms); Tier-2 `insta` snapshot `hint_block_with_concept`; Tier-3
|
||||||
|
integration tests (type a partial relationship command, move the cursor
|
||||||
|
into `on delete`, F1 → form block + referential-actions concept block,
|
||||||
|
**buffer / cursor / completion memo untouched**); the comprehensiveness
|
||||||
|
+ `keys.rs` validation tests (D7).
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Concept hints on the submitted `hint` command** — OOS: no cursor; the
|
||||||
|
last-error route is unchanged (D6).
|
||||||
|
- **A concept block for every clause in the grammar** — OOS for v1: the
|
||||||
|
eight wrappers cover the highest-value relational concepts (D4). More
|
||||||
|
can be added additively later, gated by the comprehensiveness test.
|
||||||
|
- **Surfacing concept text in the always-on tier-2 ambient panel** — OOS
|
||||||
|
(rejected): tier-2 stays terse by design (ADR-0022); the concept layer
|
||||||
|
is on-demand via F1, consistent with all of tier-3.
|
||||||
|
- **A `hint.concept.<topic>` reachable by an explicit argument** (e.g.
|
||||||
|
`hint cascade`) — OOS: ADR-0053 D1 keeps `hint` argument-free; `help
|
||||||
|
<topic>` owns explicit reference lookup.
|
||||||
|
- **Localisation beyond `en-US`** — OOS (deferred): the catalogue is i18n-
|
||||||
|
structured (ADR-0019) but English-only for v1 (requirements X2).
|
||||||
|
|
||||||
|
## Content inventory (implementation tracking)
|
||||||
|
|
||||||
|
Seven `hint.concept.<topic>` blocks, authored to the ADR-0053 D7 voice
|
||||||
|
(`what` / `concept` always; example(s) per mode coverage):
|
||||||
|
|
||||||
|
- [x] `referential_actions` *(both — 2 examples)* — on delete/update;
|
||||||
|
cascade / set null / restrict / no action.
|
||||||
|
- [x] `cardinality_one_to_many` *(simple — 1)* — one parent owns many
|
||||||
|
children; the FK lives on the child.
|
||||||
|
- [x] `cardinality_many_to_many` *(simple — 1)* — junction table; ADR-0045
|
||||||
|
auto-creation.
|
||||||
|
- [x] `primary_key` *(both — 2)* — uniquely identifies a row; shared by
|
||||||
|
`with pk` (simple) and the `create table` constraint (advanced).
|
||||||
|
- [x] `unique` *(both — 2)* — no two rows share the value; distinct from a
|
||||||
|
PK.
|
||||||
|
- [x] `check` *(both — 2)* — a per-row rule enforced on write.
|
||||||
|
- [x] `foreign_key` *(advanced — 1)* — a child column promising to point
|
||||||
|
at a real parent.
|
||||||
|
|
||||||
|
Implementation artifacts: the `Node::Concept` wrappers (D4 table, ~10
|
||||||
|
across both grammars + the two extracted cardinality sub-`Seq`s); the
|
||||||
|
`CONCEPT_TOPICS` coverage table (topic + `ModeCoverage`); the `keys.rs`
|
||||||
|
declarations (mode-keyed example leaves for the four both-mode topics);
|
||||||
|
the `hint_block_with_concept` snapshot; the CHANGELOG bullet.
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# ADR-0059: Branch-and-PR working method — worktrees, merge commits, and reserve-first ADR numbering
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted — **2026-06-23**, on branch `chore/dev-workflow` (pending merge).
|
||||||
|
Number **0059**, the second ADR reserved via the reserve-first flow this
|
||||||
|
ADR itself defines (the first was ADR-0058). Updates the **Numbering
|
||||||
|
discipline** section of **ADR-0000** (supersedes its placeholder-until-merge
|
||||||
|
default with reserve-first). Does not affect the subproject ADR namespaces
|
||||||
|
(`docs/website/adr/`, `docs/ci/adr/`).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The repository is now public, and feature work is moving from
|
||||||
|
commit-straight-to-`main` toward branches. A public, multi-branch project
|
||||||
|
needs a written, trackable working method instead of ad-hoc habits.
|
||||||
|
|
||||||
|
The sharp edge that forced the issue is **ADR numbering**. ADR numbers are
|
||||||
|
a single global integer sequence; two branches that each grab "the next
|
||||||
|
number" collide on merge (this happened, and was the original motivation
|
||||||
|
for ADR-0000's numbering rules). The deeper problem is fundamental:
|
||||||
|
|
||||||
|
> A contiguous integer requires a single allocator. Plain git branches have
|
||||||
|
> **no shared allocator** until they reconverge at merge — so any number
|
||||||
|
> claimed independently on a branch is speculative and can collide.
|
||||||
|
|
||||||
|
Earlier work papered over this for two long-lived subprojects by giving
|
||||||
|
them their **own namespaces** (`ADR-website-NNN`, `ADR-ci-NNN`), which is
|
||||||
|
right for a nameable body of decisions but doesn't generalise to every
|
||||||
|
feature branch. ADR-0000 also wrote a **placeholder-until-merge** default
|
||||||
|
("assign the number at merge"), but it had never actually been exercised,
|
||||||
|
and exercising it revealed two fatal frictions:
|
||||||
|
|
||||||
|
1. **ADR numbers are referenced from the first commit onward** — in commit
|
||||||
|
messages (which are immutable under our no-rewrite rule, so a wrong
|
||||||
|
number there is wrong *forever*), in other ADRs, in the PR. A number
|
||||||
|
that only crystallises at merge leaves every earlier reference dangling.
|
||||||
|
2. ADRs are *not* low-collision in practice: they're born precisely on the
|
||||||
|
**long-lived feature branches most likely to run in parallel**, so
|
||||||
|
"rare collision" is not a safe assumption.
|
||||||
|
|
||||||
|
So the requirement is a number that is **stable from creation, contiguous,
|
||||||
|
and collision-free** — which is only possible by acquiring a lock *up
|
||||||
|
front*. The insight that unlocks it: **`main` is the registry and an atomic
|
||||||
|
`git push` is the lock** — git serialises ref updates, so a push is a
|
||||||
|
compare-and-swap. We just have to acquire it *before* referencing the
|
||||||
|
number, not at merge.
|
||||||
|
|
||||||
|
This is a **genuine-choice** area (reasonable practitioners run GitHub
|
||||||
|
Flow, Git Flow, trunk-based, etc.); the decisions below adapt mainstream
|
||||||
|
practice to this repo's constraints (append-only history, self-hosted
|
||||||
|
Gitea, solo-with-subagents development) rather than claiming a canonical
|
||||||
|
default.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### D1 — Everything lands via a PR onto a protected `main`
|
||||||
|
|
||||||
|
`main` is always-green and protected; no direct pushes (one narrow
|
||||||
|
exception — D4). Every change is a branch + pull request onto `main`. One
|
||||||
|
logical change per branch/PR. Branch names carry a conventional prefix
|
||||||
|
(`feat/ fix/ docs/ chore/ refactor/ test/ ci/`) aligned with the commit
|
||||||
|
type. PRs reference their issue (`Closes #N`); the review we already
|
||||||
|
perform (the Devil's-Advocate / `/runda` pass) is pasted into the PR so the
|
||||||
|
public repo carries the audit trail.
|
||||||
|
|
||||||
|
### D2 — One worktree per branch (never switch the primary checkout)
|
||||||
|
|
||||||
|
Each branch is developed in its **own git worktree**, a sibling of the
|
||||||
|
primary checkout named `<repo>-worktree-<last-segment-of-branch>` (the
|
||||||
|
convention already in use for the `website` and `ci` worktrees). Switching
|
||||||
|
the primary checkout's branch while another shell sits in the same
|
||||||
|
directory is how work gets clobbered — worktrees make that impossible.
|
||||||
|
Helpers:
|
||||||
|
|
||||||
|
- **`scripts/wt-new.sh <branch>`** — fetch `main`, create the branch off
|
||||||
|
`origin/main` (with `--no-track`, so it doesn't inherit `origin/main` as
|
||||||
|
upstream — otherwise a bare `git push` errors on the name mismatch and
|
||||||
|
suggests the dangerous `HEAD:main`), and add the sibling worktree; prints
|
||||||
|
its path. First push is `git push -u origin <branch>`.
|
||||||
|
- **`scripts/wt-rm.sh <branch>`** — remove the worktree for the **named**
|
||||||
|
branch, and nothing else. It does **not** scan for "merged" worktrees to
|
||||||
|
auto-remove: long-lived branches (`website`, the `ci` line) are merged
|
||||||
|
into `main` too, so a merged-detection sweep would wrongly delete them
|
||||||
|
(caught immediately on review, before any real use — the initial design
|
||||||
|
was an auto-sweep `wt-clean`). Naming the target is the safety. Git's own
|
||||||
|
refusals do the rest: it won't remove a dirty worktree (no `--force`),
|
||||||
|
and it deletes the local branch only if it is merged into `origin/main`
|
||||||
|
(otherwise the worktree goes but the branch + its unmerged commits stay).
|
||||||
|
Stale bookkeeping for a hand-deleted directory is the built-in
|
||||||
|
`git worktree prune`'s job.
|
||||||
|
|
||||||
|
### D3 — Merge commits (`--no-ff`); never rebase or squash
|
||||||
|
|
||||||
|
Branches land with a **merge commit** (`--no-ff`). This is the only
|
||||||
|
strategy consistent with the project's append-only / no-history-rewrite
|
||||||
|
rule: squash and rebase-merge both rewrite history. Fast-forward-only is
|
||||||
|
out because it would require rebasing branches onto `main`. History gains
|
||||||
|
merge bubbles; that is the accepted cost of never rewriting.
|
||||||
|
|
||||||
|
### D4 — Reserve-first ADR numbering via `adr-reserve.sh`
|
||||||
|
|
||||||
|
ADR numbers are **reserved up front, the moment you know an ADR is
|
||||||
|
needed** — which may be mid-branch or late, so reservation is decoupled
|
||||||
|
from branch creation. `scripts/adr-reserve.sh <slug> [title]`:
|
||||||
|
|
||||||
|
1. reads an append-only ledger (`docs/adr/RESERVATIONS.log`) plus the
|
||||||
|
existing `NNNN-*.md` ADRs on `main`; next = highest + 1;
|
||||||
|
2. appends `<NNNN> <slug> <date> <title>`, commits, and **pushes to
|
||||||
|
`main`**;
|
||||||
|
3. if the push is rejected because `main` moved (someone reserved first),
|
||||||
|
it re-fetches, recomputes, and retries — the push is the compare-and-swap,
|
||||||
|
so allocations are unique by construction, not by luck.
|
||||||
|
|
||||||
|
It runs from any branch/worktree without touching the working tree (it does
|
||||||
|
the allocation in a throwaway worktree on a detached `origin/main`), and on
|
||||||
|
a *non-race* rejection (e.g. protection misconfigured) it **aborts loudly**
|
||||||
|
rather than spinning. The number is therefore **stable from the moment it
|
||||||
|
returns** — safe to cite in immutable commit messages and other ADRs from
|
||||||
|
the first commit, which is what the references-problem (Context) demands.
|
||||||
|
|
||||||
|
The ADR file is created as `docs/adr/NNNN-<slug>.md` in the working branch
|
||||||
|
and its README index row is added there as normal work; the ledger line on
|
||||||
|
`main` is the allocation record and never conflicts with feature work
|
||||||
|
(feature branches don't touch it). The ledger may be pruned later; it's a
|
||||||
|
lock record, not state. **Collisions** (two reservations in the same
|
||||||
|
instant) are resolved automatically by the retry; the README index edit is
|
||||||
|
a secondary tripwire if one ever slips through.
|
||||||
|
|
||||||
|
This **supersedes ADR-0000's placeholder-until-merge default.** Subproject
|
||||||
|
namespaces (`ADR-website-NNN`, `ADR-ci-NNN`) are unchanged — they solve a
|
||||||
|
different problem (a nameable long-lived body of decisions) and stay.
|
||||||
|
|
||||||
|
### D5 — Branch protection configuration
|
||||||
|
|
||||||
|
On `main` (Gitea → Settings → Branches):
|
||||||
|
|
||||||
|
- **Require pull requests** + **require the CI status check** (`ci / gate*`)
|
||||||
|
to pass before merge, and **block merge of out-of-date branches** (this
|
||||||
|
is what makes "`main` is the lock" airtight for the substantive merges).
|
||||||
|
- **Whitelist the repo owner for direct push.** This is the one narrow
|
||||||
|
carve-out that lets `adr-reserve.sh` push the append-only ledger line
|
||||||
|
directly; everything substantive still goes through a PR. Without it the
|
||||||
|
reserve script aborts with a clear "is the owner whitelisted?" message.
|
||||||
|
|
||||||
|
### D6 — Who does what
|
||||||
|
|
||||||
|
**Pushing and merging are human steps**; automated agents prepare branches,
|
||||||
|
edits, PR text, and commit-message proposals but never push or merge. The
|
||||||
|
reserve script is **run by the human** (so the ledger push originates from
|
||||||
|
the human's invocation, consistent with that rule) — agents may write and
|
||||||
|
test the script but not run its pushing path.
|
||||||
|
|
||||||
|
## Forks (all user-chosen, 2026-06-22/23)
|
||||||
|
|
||||||
|
- **Merge commits (`--no-ff`)** over squash or fast-forward-only — the only
|
||||||
|
option consistent with append-only history (D3).
|
||||||
|
- **Strict everything-via-PR** over allowing trivial direct commits — one
|
||||||
|
carve-out only, the reservation line (D1/D4/D5).
|
||||||
|
- **Branch protection enforced in Gitea** over convention-only — mechanical
|
||||||
|
guarantee for a public repo (D5).
|
||||||
|
- **Reserve-first contiguous integers** over: number-on-merge (rejected —
|
||||||
|
breaks immutable references), Gitea-issue-number ids (rejected — non-
|
||||||
|
contiguous, overlaps the legacy 0001–0057 range), or an allocator bot
|
||||||
|
(rejected — machinery + a PR-rule exemption). Reserve-first keeps the
|
||||||
|
short stable "ADR 0059" reference and needs no new infrastructure beyond
|
||||||
|
one script (D4).
|
||||||
|
- **Worktrees prescribed** over single-checkout branch switching — prevents
|
||||||
|
the same-directory clobber hazard (D2).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A written, public-facing **`CONTRIBUTING.md`** (lean: setup, the CI gate,
|
||||||
|
branch/PR conventions, the user-facing copy rules, "significant changes
|
||||||
|
get an ADR"). The maintainer-facing mechanics (worktrees, the reserve
|
||||||
|
lock, the whitelist) live here and in `CLAUDE.md`, not in CONTRIBUTING.
|
||||||
|
- Three scripts enter `scripts/`: `adr-reserve.sh`, `wt-new.sh`,
|
||||||
|
`wt-rm.sh`, each with a local-`origin` test harness
|
||||||
|
(`test-adr-reserve.sh`, `test-wt.sh`) — the reserve script's allocation,
|
||||||
|
idempotence, race-retry discriminator, and hard-abort paths are all
|
||||||
|
covered (the real-remote push is exercised by the human running it).
|
||||||
|
- `docs/adr/RESERVATIONS.log` appears on `main` as the allocation ledger.
|
||||||
|
- ADR-0000's Numbering-discipline section is updated to point here.
|
||||||
|
- `CLAUDE.md` gains the operational rules an agent must follow.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Migrating the legacy `0001–0057` ADRs** to any new scheme — they keep
|
||||||
|
their numbers; reserve-first applies going forward (next is `0060`).
|
||||||
|
- **Automating PR creation/merge** — humans push and merge (D6).
|
||||||
|
- **A CONTRIBUTING-side description of the reserve lock** — it's maintainer
|
||||||
|
infrastructure, not contributor guidance (kept here instead).
|
||||||
|
|
||||||
|
## Amendment 1 (2026-06-25): docs direct-push carve-out
|
||||||
|
|
||||||
|
Extends the D5 owner-whitelist: **handoff / session docs (and small docs
|
||||||
|
amendments like this one) are owner-direct-pushed to `main`, not PR'd.**
|
||||||
|
|
||||||
|
Discovered while preparing the first handoff under this flow: a **docs-only
|
||||||
|
PR cannot merge** under the protection rules. `ci.yaml` `paths-ignore`s
|
||||||
|
`docs/**` and `**/*.md`, so a docs-only change posts **no `ci / gate` run**;
|
||||||
|
but D5 requires `ci / gate*` before merge — so the required check never
|
||||||
|
arrives and the PR stalls on "expected/waiting". Rather than weaken the gate
|
||||||
|
or add CI machinery, this trivial low-risk class (handoffs, session notes,
|
||||||
|
small docs fixes) rides the existing owner-direct-push carve-out: the owner
|
||||||
|
is already whitelisted, and a docs-only push to `main` is itself
|
||||||
|
`paths-ignore`d, so it burns no CI.
|
||||||
|
|
||||||
|
Substantive changes — code, and the ADRs/docs that accompany code — still go
|
||||||
|
through a PR as normal (their non-docs files trigger the gate). **If
|
||||||
|
docs-via-PR is ever wanted** (e.g. a docs-only ADR through review), the clean
|
||||||
|
fix is a "skipped → success" gate shim (a job that reports `ci / gate`
|
||||||
|
success when the real gate is path-skipped) or dropping the docs
|
||||||
|
`paths-ignore`; until then, direct-push is the sanctioned path for docs-only
|
||||||
|
changes.
|
||||||
+18
-5
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
0058 clause-concept-hints 2026-06-23 Clause-concept hints
|
||||||
|
0059 dev-workflow 2026-06-23 Branch-and-PR working method
|
||||||
@@ -133,3 +133,13 @@ declaration of the dev *and* build environment.
|
|||||||
flake for `requirements.md` **TT5** (CI runs the tiers) and the
|
flake for `requirements.md` **TT5** (CI runs the tiers) and the
|
||||||
**D1/D2/D3** distribution items (the release uses a static musl target
|
**D1/D2/D3** distribution items (the release uses a static musl target
|
||||||
built through this flake).
|
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-002** (the nix flake, which now carries the cross toolchain) and
|
||||||
**ADR-ci-001** (the pipeline, whose release job this fills in).
|
**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)
|
## Amendment — 2026-06-14: macOS implemented (closes D1)
|
||||||
|
|
||||||
macOS is no longer deferred. The two `*-apple-darwin` targets now build on a
|
macOS is no longer deferred. The two `*-apple-darwin` targets now build on a
|
||||||
|
|||||||
@@ -19,5 +19,5 @@ here too).
|
|||||||
## Index
|
## 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-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-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; 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-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,181 @@
|
|||||||
|
# Website-branch handoff — 2026-06-10 (website-1)
|
||||||
|
|
||||||
|
First handoff for the **website** work. This is a **separate sequence** from
|
||||||
|
`main`'s `YYYYMMDD-handoff-NN.md` files — branch-scoped name on purpose, so
|
||||||
|
the two don't collide. Continue numbering these `…-website-handoff-N.md`.
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
- **Branch:** `website`. **HEAD `936d925`.** Not pushed (push is the user's
|
||||||
|
step). Working tree clean.
|
||||||
|
- The website lives in **`website/`** (monorepo; the playground crate is at
|
||||||
|
the repo root). Decisions: **ADR-0044**
|
||||||
|
(`docs/adr/0044-public-website-and-documentation-site.md`). Implementation
|
||||||
|
plan: **`docs/plans/20260604-adr-0044-website.md`**. Living style guide:
|
||||||
|
**`website/STYLE.md`** (read this first — it has the binding conventions
|
||||||
|
and an open-decisions log).
|
||||||
|
|
||||||
|
## Stack & layout
|
||||||
|
|
||||||
|
- **Astro 6 + Starlight + Tailwind v4**, all under `website/`.
|
||||||
|
- `website/astro.config.mjs` — Starlight config (title, 5-section sidebar,
|
||||||
|
Expressive Code `langs`, `server.host: '127.0.0.1'`).
|
||||||
|
- `website/src/grammars/rdbms.mjs` — custom Shiki grammar, **two language
|
||||||
|
ids**: `rdbms` (real commands) and `rdbms-syntax` (abstract templates).
|
||||||
|
- `website/src/styles/global.css` — Starlight↔Tailwind bridge + the `> `
|
||||||
|
command prompt + copy-button hiding (CSS `:has()`).
|
||||||
|
- `website/src/content/docs/` — the five sections.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd website
|
||||||
|
pnpm install # node_modules is gitignored — reinstall on a fresh checkout
|
||||||
|
pnpm dev # serves http://127.0.0.1:4321 (see dev-server gotcha below)
|
||||||
|
pnpm build # static dist/, 24 pages, Pagefind search index
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dev-server gotcha (already fixed, don't re-break):** Astro/Vite's default
|
||||||
|
`localhost` bind resolves to IPv6 `::1` here, which breaks SSH
|
||||||
|
`-L 4321:127.0.0.1:4321` tunnels. `server.host: '127.0.0.1'` in the config
|
||||||
|
pins IPv4. Tunnel with `ssh -L 4321:127.0.0.1:4321 <host>`.
|
||||||
|
|
||||||
|
**Verify after changes:** `pnpm build` clean; then from the repo root
|
||||||
|
`grep -rniE '\b(DSL|SQLite|STRICT|rusqlite|PRAGMA)\b' website/src/content/`
|
||||||
|
must be empty; internal links should resolve (build doesn't fail on broken
|
||||||
|
links — no validator installed — so sanity-check by hand or with a small
|
||||||
|
script).
|
||||||
|
|
||||||
|
## Documentation structure (5 sections, autogenerated per directory)
|
||||||
|
|
||||||
|
1. **Getting started** — install, first-project, modes, example-library. **(real)**
|
||||||
|
2. **Using the playground** — command-line-options, the-assistive-editor,
|
||||||
|
the-output-pane, projects, undo-and-history, export-and-import,
|
||||||
|
copy-to-clipboard, getting-help. **(real, grounded)** *This is "the app you
|
||||||
|
drive", distinct from the database-language Reference.*
|
||||||
|
3. **Guides** — build-the-library **(DRAFT — marked; to be iterated for
|
||||||
|
teaching quality before publication)**.
|
||||||
|
4. **Reference** — types **(real)**, tables **(real)**; columns,
|
||||||
|
relationships, indexes, constraints, inserting-and-editing-data,
|
||||||
|
querying-and-inspecting **(STUBS — real syntax synopsis + an "In progress"
|
||||||
|
note; THEY NEED WORKED EXAMPLES — this is the main remaining bulk)**.
|
||||||
|
5. **Concepts** — projects-and-storage **(real)**.
|
||||||
|
- Landing: `index.mdx` splash (feature cards incl. the assistive editor +
|
||||||
|
start-here links).
|
||||||
|
|
||||||
|
## Binding conventions (STYLE.md / ADR-0044 §7)
|
||||||
|
|
||||||
|
- **No "DSL"** in user-facing copy → "simple mode" / "advanced mode".
|
||||||
|
- **No engine name** (SQLite/STRICT/rusqlite/PRAGMA) → "the database" / "the
|
||||||
|
engine".
|
||||||
|
- **Code fences:** simple-mode commands → ` ```rdbms ` (highlighted; gets a
|
||||||
|
decorative copy-safe `> ` prompt via CSS). Abstract syntax templates →
|
||||||
|
` ```rdbms-syntax ` (highlighted, **no** prompt, no copy button). Advanced
|
||||||
|
SQL → ` ```sql `. Shell → ` ```sh `.
|
||||||
|
- **One command per line** in `rdbms` blocks. A multi-line *single* statement
|
||||||
|
(advanced `CREATE TABLE`) goes in ` ```sql `.
|
||||||
|
- **Copy button** is hidden on multi-line `rdbms` blocks and on
|
||||||
|
`rdbms-syntax` (the app input is single-line — a multi-command paste isn't
|
||||||
|
runnable); kept on single-command `rdbms`, and all `sql`/`sh`.
|
||||||
|
- Unshipped features → `:::caution[Planned]` aside; never presented as
|
||||||
|
shipped.
|
||||||
|
- **Ground every page in source** — `parse.usage.*` / `help.*` in
|
||||||
|
`src/friendly/strings/en-US.yaml`, `src/dsl/command.rs`, `src/dsl/types.rs`,
|
||||||
|
the ADRs. **Do not** trust `requirements.md` markers (handoff-59 found
|
||||||
|
~46% mis-marked; it now uses a `[/]` partial legend) — verify against code.
|
||||||
|
|
||||||
|
## Canonical example database (use in every example)
|
||||||
|
|
||||||
|
A small **library**: `authors`(author_id serial pk, name text, birth_year
|
||||||
|
int) · `books`(book_id serial pk, title text, author_id int→authors,
|
||||||
|
published int, isbn text unique) · `members`(member_id serial pk, name text,
|
||||||
|
joined date) · `loans`(loan_id serial pk, book_id int→books, member_id
|
||||||
|
int→members, loaned_on date, returned_on date). 1:n author→books; m:n
|
||||||
|
books↔members via loans. **Simplest examples lead with bare `with pk`** (a
|
||||||
|
default `id` key); the library build uses **named** keys (`author_id`, …) so
|
||||||
|
relationships read clearly.
|
||||||
|
|
||||||
|
## Verified syntax cheat-sheet (don't re-derive)
|
||||||
|
|
||||||
|
- Simple create: `create table <T> with pk [<col>(<type>)[, ...]]` (bare =
|
||||||
|
default `id`); `add column [to] [table] <T>: <name> (<type>)`;
|
||||||
|
`rename column [in] [table] <T>: <old> to <new>`; `change column … (<type>)
|
||||||
|
[--force-conversion|--dont-convert]`; `drop column [from] [table] <T>: <col>
|
||||||
|
[--cascade]`.
|
||||||
|
- Advanced create: `create table T (id serial primary key, …, col int
|
||||||
|
references parent(col))` (verified against `tests/it/sql_create_table.rs`).
|
||||||
|
- Relationship: `add 1:n relationship [as <name>] from <P>.<col> to <C>.<col>
|
||||||
|
[on delete <action>] [on update <action>] [--create-fk]`;
|
||||||
|
compound: `from <P>.(a, b) to <C>.(x, y)`; drop by name or by endpoints.
|
||||||
|
- Data: `insert into T [(cols)] [values] (vals)`; `update T set … (where … |
|
||||||
|
--all-rows)`; `delete from T (where … | --all-rows)`.
|
||||||
|
- Inspect: `show data <T> [where …] [limit n]`, `show table <T>`, `show
|
||||||
|
tables|relationships|indexes`, `show relationship|index <name>`.
|
||||||
|
- `explain <…>` (query plan; safe — never executes). Advanced `select …`.
|
||||||
|
- 10 types: text int real decimal bool date datetime blob serial shortid;
|
||||||
|
advanced-mode SQL aliases (integer/varchar/timestamp/numeric/…).
|
||||||
|
- App commands: save / save as / new / load / rebuild / export [path] /
|
||||||
|
import <zip> [as <t>] / undo / redo / replay <path> / mode / help
|
||||||
|
[<command>] / help types / copy [all|last] / quit. (`hint` and `seed` are
|
||||||
|
NOT implemented — mark planned/omit.)
|
||||||
|
|
||||||
|
## Next work (priority order)
|
||||||
|
|
||||||
|
1. **Fill the 6 Reference stubs** with worked examples on the library schema
|
||||||
|
(the remaining bulk). Each has a syntax synopsis + an "In progress" note —
|
||||||
|
expand to full content: worked example(s), both simple + advanced forms
|
||||||
|
where both apply, cross-links. This is the biggest chunk.
|
||||||
|
2. **Iterate the Guides** for teaching quality; add guides (model a 1:n / m:n,
|
||||||
|
querying with joins). The user flagged guides as the most important
|
||||||
|
didactic content, to be polished before publication.
|
||||||
|
3. **Phase B — landing polish:** use the `frontend-design` skill; set
|
||||||
|
Starlight `site` (production URL) once the domain is known (enables sitemap
|
||||||
|
+ OG/SEO); add a logo + favicon (small Starlight config). Branding palette
|
||||||
|
when the user wants it (staying on Starlight; community themes were
|
||||||
|
surveyed — see ADR-0044 / chat).
|
||||||
|
4. **asciinema casts — DEFERRED until the app is final** (ADR-0044 §2). When
|
||||||
|
starting, settle STYLE.md open-decision #9: scripted-input driver
|
||||||
|
(`asciinema-automation` vs `autocast` — prove with a throwaway test run),
|
||||||
|
`.cast` script format + repo location, terminal geometry, light/dark
|
||||||
|
player theme, file naming. The **assistive editor** is prime cast material
|
||||||
|
(completion / `[ERR]`/`[WRN]` indicator are motion a still block can't
|
||||||
|
show) — earmark a cast for it on the landing + the assistive-editor page.
|
||||||
|
5. Remaining STYLE.md open decisions: **versioning** (leaning single-version
|
||||||
|
for launch) and **SEO/meta** (settle with Phase B + the `site` URL).
|
||||||
|
|
||||||
|
## Process pins
|
||||||
|
|
||||||
|
- **Commits:** user-confirmed (show the message first), **no AI attribution**,
|
||||||
|
**append-only** (no amend/rebase/force-push). Push is the user's step.
|
||||||
|
- **ADR numbering:** assigned at merge-to-`main` (ADR-0000 "Numbering
|
||||||
|
discipline", added this branch). The website ADR is **0044** — renumbered
|
||||||
|
from 0042 on the `main` merge, because `main` had independently used 0042
|
||||||
|
(H1a) and 0043 (compound-FK).
|
||||||
|
- **Issues:** Gitea via `tea` (repo `oli/rdbms-playground` on
|
||||||
|
`git.lazyeval.net`); append `< /dev/null` + `timeout 30`; never raw API.
|
||||||
|
- **Escalate genuine forks**, declare epistemic status, write down the DA pass
|
||||||
|
(`/runda`) on non-trivial plans.
|
||||||
|
|
||||||
|
## Commit history on this branch (newest first)
|
||||||
|
|
||||||
|
```
|
||||||
|
936d925 feat: add "Using the playground" section + Reference skeleton
|
||||||
|
44390e7 feat: simple-mode code-block highlighting, prompt, and copy rules
|
||||||
|
995c0ba docs: reconcile website doc inventory with merged main scope
|
||||||
|
c72c624 chore: bind website dev/preview server to IPv4 loopback (127.0.0.1)
|
||||||
|
9e774b2 docs: ADR numbering discipline — assign numbers at merge-to-main
|
||||||
|
40de389 Merge branch 'main' into website (Gitea migration + ADR renumber)
|
||||||
|
0fcb7b1 docs: website docs structure + first content pages
|
||||||
|
cea99e8 chore: scaffold website (Astro 6 + Starlight + Tailwind v4)
|
||||||
|
1fad29c docs: ADR-0042 — public website + documentation site plan (now ADR-0044)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Review status (what the user has signed off)
|
||||||
|
|
||||||
|
Highlighting / `> ` prompt / copy behavior — good. Voice, altitude,
|
||||||
|
terminology — good. Responsive layout (checked in Polypane) — good. Locked
|
||||||
|
decisions: bare `with pk` leads the simplest examples; copy hidden on
|
||||||
|
multi-command blocks (not per-command copy); the 5-section structure with
|
||||||
|
"Using the playground" near the top; assistive editor surfaced on
|
||||||
|
landing + Getting started. **The 6 Reference stubs have not been reviewed for
|
||||||
|
content** — only their syntax synopses exist.
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# Website-branch handoff — 2026-06-11 (website-2)
|
||||||
|
|
||||||
|
Second handoff for the **website** work (separate sequence from `main`'s
|
||||||
|
`YYYYMMDD-handoff-NN.md`). Read `website-1` (2026-06-10) first for the
|
||||||
|
original scaffolding context; this note covers everything since.
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
- **Branch:** `website`. **HEAD `e782a28`.** Working tree clean. Pushed
|
||||||
|
through an earlier point; currently **ahead of `origin/website`** (push is
|
||||||
|
the user's step — never push).
|
||||||
|
- The website lives in **`website/`** (monorepo; the playground crate is at
|
||||||
|
the repo root). Living style guide + binding conventions:
|
||||||
|
**`website/STYLE.md`** (read first). Website decisions:
|
||||||
|
**`docs/website/adr/20260604-adr-website-001.md`** (the website ADR namespace
|
||||||
|
— see below). Plan: `docs/website/plans/20260604-website-implementation-plan.md`.
|
||||||
|
|
||||||
|
## What changed since website-1 (commit highlights, newest first)
|
||||||
|
|
||||||
|
```
|
||||||
|
e782a28 feat(website): projects cast (vi-nav load picker) + --demo on all casts
|
||||||
|
927e6b2 Merge branch 'main' (m:n, logging, UI nav, demo overlays, vi-nav)
|
||||||
|
52860c3 feat(website): casts for first-project/modes/undo-redo; quit via Ctrl-C
|
||||||
|
ce153bd docs(website): add SQL queries reference page (advanced query surface)
|
||||||
|
302329d docs(website): record the cast-placement policy in STYLE.md
|
||||||
|
bb7887e feat(website): relationship-diagram cast on the relationships page
|
||||||
|
65a48fa feat(website): joins cast on the querying-with-joins guide
|
||||||
|
c0cc92a docs(website): rewrite Build the library + add Querying with joins guide
|
||||||
|
10655e4 docs(website): fill the six Reference stubs with worked examples + output
|
||||||
|
a8f84c9 feat(website): refine casts — trim shell, autoplay+loop landing, cap size
|
||||||
|
… (cast pipeline, the astro/starlight upgrade, the ADR-namespace move)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ADR namespace (important — avoids the recurring collision)
|
||||||
|
|
||||||
|
Website decision records live in their **own namespace**:
|
||||||
|
`docs/website/adr/` (`<date>-adr-website-<NNN>.md`, id `ADR-website-NNN`),
|
||||||
|
indexed by `docs/website/adr/README.md`. They do **not** draw from `main`'s
|
||||||
|
global ADR integer pool, so a `main` ADR and a website ADR can never collide
|
||||||
|
again (this is why the latest merge of `main`'s ADR-0045/0046/0047 was
|
||||||
|
conflict-free). Recorded in ADR-0000 "Numbering discipline". The main
|
||||||
|
`docs/adr/README.md` intro carries a pointer to the website namespace.
|
||||||
|
|
||||||
|
## Content status
|
||||||
|
|
||||||
|
**Done & verified (build clean, grounded in source, forbidden-terms clean):**
|
||||||
|
- **Getting started** — installation, first-project, modes, example-library.
|
||||||
|
- **Using the playground** — command-line-options, the-assistive-editor,
|
||||||
|
the-output-pane, projects, undo-and-history, export-and-import,
|
||||||
|
copy-to-clipboard, getting-help.
|
||||||
|
- **Guides** — build-the-library (full 4-table build, 1:n + m:n bridge),
|
||||||
|
querying-with-joins.
|
||||||
|
- **Reference** — types, tables, columns, relationships, indexes, constraints,
|
||||||
|
inserting-and-editing-data, querying-and-inspecting, **sql-queries** (the
|
||||||
|
advanced SELECT surface: DISTINCT, GROUP BY/HAVING, set ops, subqueries,
|
||||||
|
CTEs, CASE/CAST/functions, with a "supported subset" boundary note).
|
||||||
|
- **Concepts** — projects-and-storage.
|
||||||
|
- Landing `index.mdx` splash with the quickstart cast.
|
||||||
|
|
||||||
|
**26 pages build clean.** Only expected warning: sitemap needs `site` (Phase B).
|
||||||
|
|
||||||
|
## asciinema casts — the pipeline + the 7 casts
|
||||||
|
|
||||||
|
Pipeline (STYLE.md "asciinema casts", ADR-website-001 §2):
|
||||||
|
- **Driver: `autocast`** (chosen by spike; `asciinema-automation` can't drive a
|
||||||
|
full-screen TUI). Sources in `website/casts-src/casts.mjs`; `pnpm casts`
|
||||||
|
runs `casts-src/generate.mjs` → autocast YAML → `public/casts/<name>.cast`.
|
||||||
|
Command-lists are the durable source; **`.cast` files are regenerable** —
|
||||||
|
re-run `pnpm casts` (needs a `cargo build` binary at `../target/debug`).
|
||||||
|
- **Components:** `Cast.astro` (asciinema-player island) wrapped by
|
||||||
|
`Demo.astro` (the WASM-swap seam, ADR-website-001 §3). Embed via `<Demo
|
||||||
|
src="/casts/NAME.cast" … />` in an **.mdx** page (md can't import).
|
||||||
|
- **Generator features:** trims each cast to the in-app region (drops shell
|
||||||
|
launch + the return-to-shell); ends casts with **Ctrl-C** (`{ key: 'CtrlC' }`)
|
||||||
|
not a typed `quit` (invisible, so the cast ends on the last content frame);
|
||||||
|
per-cast `holdEnd`, `dataDir` (isolated data root, wiped per run), and
|
||||||
|
**`--demo` is default-on** (opt out with `demo:false`).
|
||||||
|
- **The 7 casts:** quickstart (landing, autoplay+loop), assistive-editor,
|
||||||
|
relationship-diagram, joins, modes, undo-redo, **projects** (the new one:
|
||||||
|
save as → new → load via the picker, navigated with `j`).
|
||||||
|
|
||||||
|
### `--demo` (#22 / ADR-0047) is on for ALL casts
|
||||||
|
The demonstration overlay shows a **badge** for special keystrokes
|
||||||
|
(`[ENTER]`, `[TAB]`, arrows, etc.) — plain characters are NOT badged. This
|
||||||
|
makes e.g. the assistive-editor's Tab completion visible (`[TAB]`), and the
|
||||||
|
projects cast's `j`/`k` picker navigation stays *un*surfaced (plain chars) by
|
||||||
|
design. Captions exist too (a stealth `Ctrl+]`-delimited banner) — usable in
|
||||||
|
casts for neutral "point something out" labels, not yet used.
|
||||||
|
|
||||||
|
### ⚠️ Cast tooling limits (don't rediscover these)
|
||||||
|
- autocast can only send **single characters, ASCII control codes (`^X`), and
|
||||||
|
waits** — **NO arrows / PageUp/PageDown / Home/End / function keys** (those
|
||||||
|
are escape sequences; the per-key delay makes the terminal read a lone Esc —
|
||||||
|
verified). So any interaction we want to demo must be reachable via typeable
|
||||||
|
keys. This is why #24 (vi `j/k` in the picker) was needed for the projects
|
||||||
|
cast, and why **output-pane scrolling has no cast** (needs PgUp/PgDn).
|
||||||
|
- `Ctrl+]` (caption toggle) and `Ctrl-C` (quit) ARE sendable (`^]`, `^C`).
|
||||||
|
|
||||||
|
### ⚠️ No-advertising constraint (user, 2026-06-11)
|
||||||
|
The docs must **NOT** advertise that the load picker supports **vi keys**, nor
|
||||||
|
that **`Ctrl+]`** is the caption/banner trigger. The `--demo` flag itself MAY
|
||||||
|
be documented lightly as "a teaching helper that shows special keystrokes" —
|
||||||
|
and nothing more. Casts may *use* vi-nav and captions (the viewer sees only the
|
||||||
|
result/banner, not the keystroke), but cast captions must not name `j/k` or
|
||||||
|
`Ctrl+]`.
|
||||||
|
|
||||||
|
## NEXT WORK — priority order
|
||||||
|
|
||||||
|
### 1. Document the features the `main` merge brought (the biggest gap)
|
||||||
|
The merge (`927e6b2`) added app features that are **not yet documented**:
|
||||||
|
|
||||||
|
- **m:n convenience command** — `create m:n relationship …` (C4, **ADR-0045**).
|
||||||
|
The relationships page currently models m:n only via the manual loans-bridge.
|
||||||
|
Document the convenience command (it auto-generates the junction table).
|
||||||
|
Ground in ADR-0045 + `tests/it/m2n.rs` + `tests/typing_surface/create_m2n.rs`
|
||||||
|
for exact syntax. Likely a new section on the **relationships** reference
|
||||||
|
page and/or a mention in the build-the-library guide.
|
||||||
|
- **`--demo` flag** — document on **command-line-options** as a teaching helper
|
||||||
|
that "shows special keystrokes" (per the no-advertising constraint above —
|
||||||
|
do NOT mention badges-for-vi or captions/`Ctrl+]`).
|
||||||
|
- **ADR-0046 UI** — the **schema sidebar** (auto-shows on wide terminals,
|
||||||
|
`Ctrl-O` navigation mode to peek/expand), **responsive two-row input** +
|
||||||
|
horizontal scroll, and the geometry-fixed hint panel. Decide where in *Using
|
||||||
|
the playground* (a new "the schema sidebar" page, or fold into the-output-pane
|
||||||
|
/ the-assistive-editor). Ground in ADR-0046.
|
||||||
|
- FK-message fixes + the X1 logging sweep: **no user-doc impact** (note only).
|
||||||
|
|
||||||
|
Whenever output changed because of the merge, **re-verify any affected static
|
||||||
|
output blocks** (capture-harness recipe below).
|
||||||
|
|
||||||
|
### 2. Consider a final cast re-record sweep + optional captions
|
||||||
|
- All casts re-record with `pnpm casts` once the app is "final".
|
||||||
|
- **Chase up two pacing/clarity guidelines across the existing casts** (added
|
||||||
|
to STYLE.md "Cast pacing & clarity" 2026-06-11; the projects cast already
|
||||||
|
follows them):
|
||||||
|
1. **Don't submit a command too fast** — a typed-and-`Enter`ed-in-one-instant
|
||||||
|
command vanishes before the viewer reads it. Re-review each cast for
|
||||||
|
type-then-instant-Enter (especially modal confirms / short commands) and
|
||||||
|
add a pause before `Enter` (split `type` and `key:'Enter'` steps).
|
||||||
|
2. **Show state where the sidebar would** — at 90 cols the schema sidebar is
|
||||||
|
hidden (ADR-0046), so insert `show tables` / `show table` / `show data`
|
||||||
|
where state changes, so the viewer can follow what happened.
|
||||||
|
- **Review whether caption banners would improve the existing casts.** The
|
||||||
|
demo overlay can show a neutral step **caption** (the stealth `Ctrl+]`
|
||||||
|
banner) to label or narrate a moment — e.g. marking the phases of the
|
||||||
|
build-the-library/projects casts, or calling out the relationship diagram /
|
||||||
|
the teaching echo in the modes cast. Go cast-by-cast and decide where a
|
||||||
|
caption adds clarity vs. adds noise. Constraint: caption **text must not name
|
||||||
|
keys** (no `j/k`, no `Ctrl+]`); it narrates *what* is happening, not *how* it
|
||||||
|
was typed. (Captions are wired but not yet used in any cast.)
|
||||||
|
- Output-pane scrolling cast remains blocked (PgUp/PgDn unsendable). If desired
|
||||||
|
later, it needs an app-side typeable scroll key (file an enhancement like #24)
|
||||||
|
— otherwise leave it to static docs.
|
||||||
|
|
||||||
|
### 3. Phase B — landing/site polish
|
||||||
|
- Set Starlight **`site`** (production URL) → clears the sitemap warning,
|
||||||
|
enables sitemap + canonical/OG. Then SEO/meta conventions (STYLE #8).
|
||||||
|
- Logo + favicon; branding palette (staying on Starlight).
|
||||||
|
- **Light/dark player theme**: the asciinema player theme is currently fixed
|
||||||
|
(`asciinema`); sync it to the Starlight theme toggle (folded into STYLE #8).
|
||||||
|
- Use the `frontend-design` skill.
|
||||||
|
|
||||||
|
### 4. Open STYLE.md decisions
|
||||||
|
- **#7 Versioning** (leaning single-version for launch).
|
||||||
|
- **#8 SEO/meta** + the player light/dark theme.
|
||||||
|
|
||||||
|
## Capture-harness recipe (how to get accurate static output for new pages)
|
||||||
|
|
||||||
|
Output blocks must be **captured from the real app, never hand-drawn**
|
||||||
|
(STYLE.md). Pattern used throughout:
|
||||||
|
- For `pub` render fns (`render_data_table`, `render_explain_plan`) + the DB
|
||||||
|
worker API: a throwaway **external** test `tests/doc_capture.rs` that builds
|
||||||
|
the library schema via `Database` and prints rendered output; run
|
||||||
|
`cargo test --test doc_capture -- --nocapture --ignored`; paste verbatim
|
||||||
|
(trim trailing spaces); delete the file.
|
||||||
|
- For `pub(crate)` fns (`render_structure_with_diagrams`,
|
||||||
|
`render_relationship_diagram`): an in-crate `#[ignore]` test in
|
||||||
|
`src/output_render.rs`'s test module instead.
|
||||||
|
- **Verify box-drawing integrity** after pasting (top `┬` count == bottom `┴`,
|
||||||
|
equal line lengths) — a mis-paste truncated a CTE border once and the check
|
||||||
|
caught it.
|
||||||
|
|
||||||
|
## Verify-after-changes checklist
|
||||||
|
```sh
|
||||||
|
cd website && pnpm build # clean; 26 pages; only the `site` warning
|
||||||
|
# from repo root — forbidden terms must be empty:
|
||||||
|
grep -rniE '\b(DSL|SQLite|STRICT|rusqlite|PRAGMA)\b' website/src/content/
|
||||||
|
# internal links + heading anchors: spot-check in dist/ (no link validator installed)
|
||||||
|
cd website && pnpm casts # regenerate all casts (needs target/debug binary)
|
||||||
|
```
|
||||||
|
Dev server + tunnel for visual checks (player playback, sizing, badges):
|
||||||
|
`cd website && pnpm dev` (binds 127.0.0.1:4321) then `ssh -L 4321:127.0.0.1:4321 <host>`.
|
||||||
|
**Visual playback of all 7 casts (now with `--demo` badges) is still pending a
|
||||||
|
tunnel check by the user.**
|
||||||
|
|
||||||
|
## Process pins
|
||||||
|
- **Commits:** user-confirmed (show the message first), **no AI attribution**,
|
||||||
|
**append-only** (no amend/rebase/force-push). Push is the user's step.
|
||||||
|
- **Ground every page in source** (`src/dsl/*`, `en-US.yaml`, the ADRs) — not
|
||||||
|
`requirements.md` markers. No engine name, no "DSL" in user-facing copy.
|
||||||
|
- **Issues** via `tea` (repo `oli/rdbms-playground` on `git.lazyeval.net`;
|
||||||
|
append `< /dev/null` + `timeout 30`). Open/related: **#22** (demo overlay,
|
||||||
|
implemented), **#24** (vi picker nav, implemented). Both merged via `927e6b2`.
|
||||||
|
- Escalate genuine forks; declare epistemic status; write down the `/runda` DA
|
||||||
|
pass on non-trivial plans.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Session handoff — 2026-06-15 (71)
|
||||||
|
|
||||||
|
Short, focused handover. Continues immediately from handoff-70 (which
|
||||||
|
shipped H2 / the contextual `hint`, ADR-0053). **A user smoke-test
|
||||||
|
surfaced a correctness bug in the hint content, and it implicates the
|
||||||
|
whole corpus.** This handoff exists so the next session does a
|
||||||
|
**systematic semantic verification pass over every hint block** — context
|
||||||
|
ran too low to do it now.
|
||||||
|
|
||||||
|
## §1. State
|
||||||
|
|
||||||
|
**Branch:** `main`, clean, all committed (local; push pending). **2499
|
||||||
|
pass / 1 ignored, clippy clean.** Open issues: #35–#38 (see handoff-70).
|
||||||
|
H2 / ADR-0053 is *functionally* complete; the **content is not
|
||||||
|
trustworthy** until the pass below is done.
|
||||||
|
|
||||||
|
## §2. The bug (confirmed)
|
||||||
|
|
||||||
|
`hint.cmd.create_table` (in `src/friendly/strings/en-US.yaml`) reads:
|
||||||
|
|
||||||
|
```
|
||||||
|
What: Create a new table — its columns, their types, and a primary key.
|
||||||
|
Example: create table Customers with pk id(serial), name(text), email(text)
|
||||||
|
Concept: A table is a set of rows that share the same columns. The primary
|
||||||
|
key uniquely identifies each row; a `serial` key numbers the rows for you.
|
||||||
|
```
|
||||||
|
|
||||||
|
**This is wrong.** In the DSL, **everything after `with pk` is the
|
||||||
|
primary-key column list** (a possibly *compound* PK, ADR-0005). So the
|
||||||
|
example does **not** create a table with `pk=id` plus regular columns
|
||||||
|
`name`/`email` — it creates a table whose **compound primary key is
|
||||||
|
(id, name, email)**. Non-key columns are added *separately* with
|
||||||
|
`add column`. The `what` ("its columns, their types") and the example
|
||||||
|
both mislead a learner badly.
|
||||||
|
|
||||||
|
- **Evidence:** real test usage is `create table Orders with pk
|
||||||
|
id(serial), CustId(int)` (a 2-column *compound PK*) and the common form
|
||||||
|
`create table X with pk id(int)` (single-column PK only). The usage
|
||||||
|
template `create table <Name> with pk [<col>(<type>)[, ...]]` is itself
|
||||||
|
misleading — the `[, ...]` is the PK list, not regular columns.
|
||||||
|
- **Correct mental model:** `create table <T> with pk <pk-cols…>` then
|
||||||
|
`add column <T>: <name> (<type>)` for each non-key column. Confirm
|
||||||
|
against ADR-0005 (compound PK) and ADR-0009 (DSL syntax) when fixing.
|
||||||
|
|
||||||
|
## §3. Root cause — why this needs a *full* pass
|
||||||
|
|
||||||
|
During Phase C I verified *some* examples against `parse.usage.*`
|
||||||
|
templates and real test greps, but for others I **extrapolated** beyond
|
||||||
|
verified syntax. For `create_table` I saw `... with pk id(int)` (single
|
||||||
|
col) and wrongly generalised to "pk + more columns," misreading the
|
||||||
|
`with pk` list as a column list. The examples are **syntactically**
|
||||||
|
checked but not **semantically** — i.e. not verified to *do what the
|
||||||
|
`what`/`concept` claims*.
|
||||||
|
|
||||||
|
So the corpus needs a pass that, for **every** `hint.cmd.*` and
|
||||||
|
`hint.err.*` block, checks:
|
||||||
|
1. the `example` parses **and runs**, and
|
||||||
|
2. it actually demonstrates what `what`/`concept` says, and
|
||||||
|
3. `what`/`concept` are factually true of the real behaviour.
|
||||||
|
|
||||||
|
**Don't trust grep+extrapolation.** Prefer: run the example in the app
|
||||||
|
(or a Tier-3 test), or check it against the authoritative ADR.
|
||||||
|
|
||||||
|
## §4. The pass — how to do it (next session)
|
||||||
|
|
||||||
|
The corpus lives in `src/friendly/strings/en-US.yaml` under `hint.cmd.*`
|
||||||
|
(per command form) and `hint.err.*` (per runtime error class). The
|
||||||
|
inventory and authoritative syntax sources:
|
||||||
|
|
||||||
|
- **`hint.cmd.<form>`** — for each, cross-check the example against the
|
||||||
|
matching `parse.usage.<form>` template **and** the form's ADR, and run
|
||||||
|
it. Highest-risk (extrapolated, verify first): **DDL** — `create_table`
|
||||||
|
(known wrong), `add_column`, `add_index`, `add_constraint`,
|
||||||
|
`change_column`, `drop_*`, `create_m2n`; **advanced-SQL** — confirm
|
||||||
|
each is in the supported SQL subset (`select`, `with` CTE,
|
||||||
|
`sql_insert/update/delete`, `sql_create_table`, `sql_alter_table`,
|
||||||
|
`sql_create_index/drop_index/drop_table`, `explain_sql`); **DML** —
|
||||||
|
`seed` forms, `explain`, `show_*`, `update`/`delete` (`--all-rows` /
|
||||||
|
required-WHERE wording). App commands are lower-risk (reference-style).
|
||||||
|
- **`hint.err.<class>`** — verify the fix recipe in `example` is actually
|
||||||
|
the right remedy and `concept` matches the engine's real behaviour
|
||||||
|
(FK sides, `on delete` actions, check/not_null/unique semantics).
|
||||||
|
- Relevant ADRs: 0005 (types + compound PK), 0009 (DSL syntax), 0011 (FK
|
||||||
|
type compat), 0013 (relationships/rebuild), 0014 (data ops +
|
||||||
|
required-WHERE), 0025 (indexes), 0028/0039 (explain), 0030–0036 (SQL
|
||||||
|
subset), 0048 (seed). `docs/requirements.md` for scope.
|
||||||
|
|
||||||
|
**Suggested method:** drive the app (`/run` or a small PTY/Tier-3 harness)
|
||||||
|
and actually execute each example; or add a test that parses+runs every
|
||||||
|
`hint.cmd.*` example and asserts success. The latter would also be a
|
||||||
|
durable regression guard — consider adding it as part of the pass (it
|
||||||
|
upgrades the comprehensiveness coverage test from "a block exists" to
|
||||||
|
"the example actually works").
|
||||||
|
|
||||||
|
## §5. Immediate fix ready to apply
|
||||||
|
|
||||||
|
`create_table` is diagnosed (§2). The corrected block should make the
|
||||||
|
example a PK-only `create table` and move the regular columns to a
|
||||||
|
follow-up `add column`, e.g.:
|
||||||
|
|
||||||
|
```
|
||||||
|
What: Create a new table with its primary key.
|
||||||
|
Example: create table Customers with pk id(serial)
|
||||||
|
Concept: A table is a set of rows sharing the same columns. `with pk`
|
||||||
|
declares the primary key (one column, or several for a compound
|
||||||
|
key); add the other columns afterwards with `add column`.
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply this (and re-check `create_m2n` / `add_*` while there), but only as
|
||||||
|
part of the systematic pass — a one-off fix risks leaving siblings wrong.
|
||||||
|
|
||||||
|
## §6. How to take over
|
||||||
|
|
||||||
|
1. Read handoffs 70 → 71, `CLAUDE.md`.
|
||||||
|
2. Confirm green: `cargo test` (2499 / 1 ignored), `cargo clippy
|
||||||
|
--all-targets`.
|
||||||
|
3. Do the §4 pass (consider the run-every-example test in §4). Test-first,
|
||||||
|
`/runda` before commit, confirm the commit message with the user.
|
||||||
|
4. Pedagogy wins — these are teaching strings; correctness and clarity
|
||||||
|
over cleverness.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Session handoff — 2026-06-15 (72)
|
||||||
|
|
||||||
|
Short, focused handover. Continues from handoff-71, which asked the next
|
||||||
|
session to run a **systematic semantic verification pass over every
|
||||||
|
`hint` block** (handoff-70 shipped H2 / ADR-0053, but a user smoke-test
|
||||||
|
found a wrong hint and implicated the whole corpus). **That pass is now
|
||||||
|
done.** Four content errors fixed, a durable parse-guard added, two stale
|
||||||
|
docs corrected. Commit `5a37437`.
|
||||||
|
|
||||||
|
## §1. State
|
||||||
|
|
||||||
|
**Branch:** `main`, clean, all committed (local; **push pending** — your
|
||||||
|
step). **2500 pass / 0 fail / 1 ignored** (the long-standing `friendly`
|
||||||
|
doctest), **clippy clean** (nursery, all targets). The +1 vs handoff-71's
|
||||||
|
2499 is the new guard test. Open Gitea issues unchanged: **#35–#38**.
|
||||||
|
|
||||||
|
## §2. The verification pass (commit `5a37437`)
|
||||||
|
|
||||||
|
Method: cross-checked every `hint.cmd.*` example against its
|
||||||
|
`parse.usage.*` template, ground-truthed every concept claim against the
|
||||||
|
authoritative ADR **and a named existing test** (not grep+extrapolation —
|
||||||
|
the trap handoff-71 §3 warned about), and parse-validated all 49 command
|
||||||
|
examples via a new guard.
|
||||||
|
|
||||||
|
### Four content errors fixed (`src/friendly/strings/en-US.yaml`)
|
||||||
|
|
||||||
|
| Block | Bug | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `cmd.create_table` | Example `with pk id(serial), name(text), email(text)` declares a **3-column compound PK**, not a PK + regular columns. Every `with pk` column is a key member — confirmed by the grammar test comment *"Every `create table` column is a primary-key column"* (`ddl.rs`), ADR-0005. | Single-column PK + `add column` for the rest; `what`/`concept` aligned. |
|
||||||
|
| `cmd.save` | `save as my-shop` **does not parse** — `build_save` yields `AppCommand::SaveAs` with **no inline name**; `save as` opens a path-entry modal (`iteration4b` tests). | Example → `save as`; `what` de-implied; added an accurate temp-vs-named-auto-save `concept`. |
|
||||||
|
| `cmd.import` | Target `shop-copy` **does not parse** — the `as <target>` slot is an `IdentSource::NewName` ident that tokenises only up to the hyphen. (The zip path is a BarePath and *does* accept hyphens, hence `export my-shop.zip` is fine.) | → `shop_copy`. |
|
||||||
|
| `err.foreign_key.child_side.concept` | Offered `on delete set null/cascade` as the remedy — but `error_hint_class` maps child_side to **insert/update** violations; `on delete` governs the **parent** direction. The tier-1 hint (line 64) correctly omits it. | Corrected: parent must exist first; clarified `on delete` is the *other* direction. |
|
||||||
|
|
||||||
|
### Durable guard added
|
||||||
|
|
||||||
|
`every_cmd_hint_example_parses_in_its_mode` (`src/dsl/grammar/mod.rs`,
|
||||||
|
in the `hint_key_tests` module). **Catalog-driven** — it iterates
|
||||||
|
`catalog().keys()` for `hint.cmd.*.example` rather than the REGISTRY, so
|
||||||
|
an orphaned/mis-keyed block can't slip past; floor-asserts ≥49 examples.
|
||||||
|
Each parses in its taught mode (advanced for the SQL surface, simple
|
||||||
|
otherwise). It caught the `save` and `import` errors **test-first** (red
|
||||||
|
before the YAML fix). Registered the new `hint.cmd.save.concept` key in
|
||||||
|
`keys.rs` (the `keys_validate_against_catalog` test requires every catalog
|
||||||
|
key be declared).
|
||||||
|
|
||||||
|
### Verified correct (not changed)
|
||||||
|
|
||||||
|
All other `cmd`/`err` blocks. Notably the guard-*concept* claims were each
|
||||||
|
confirmed against a named runtime test, not assumed:
|
||||||
|
`drop_column_refuses_primary_key` / `…_column_in_a_relationship`,
|
||||||
|
`drop_table_with_inbound_relationship_errors`,
|
||||||
|
`add_not_null_column_without_default_to_populated_table_is_refused`. The
|
||||||
|
corrected `create_table` story stays coherent with the `Customers`-
|
||||||
|
referencing examples (id serial PK → `add column` name/email → `insert`
|
||||||
|
skips the auto id).
|
||||||
|
|
||||||
|
## §3. Docs corrected (same commit)
|
||||||
|
|
||||||
|
Discovered while verifying `create_m2n` (which **is** implemented —
|
||||||
|
`db.rs::do_create_m2n_relationship` + `tests/it/m2n.rs`):
|
||||||
|
|
||||||
|
- **CLAUDE.md** carried two **stale "deferred" claims**, both already
|
||||||
|
implemented. Removed/updated: (a) the at-a-glance project-format line
|
||||||
|
said export/import (Iter 5) + `--resume`/input-history/migration (Iter
|
||||||
|
6) were "pending" — all `[x]` in `requirements.md` (ADR-0015); (b) the
|
||||||
|
"Things deliberately deferred" list still had the **m:n convenience
|
||||||
|
(C4)** bullet and the same project-storage bullet. `requirements.md`
|
||||||
|
was already correct (C4 done 2026-06-10, ADR-0045), so only a
|
||||||
|
verification-pass note was appended to its **H2** entry.
|
||||||
|
|
||||||
|
## §4. Scope note — what the guard does *not* do
|
||||||
|
|
||||||
|
The bug class here is **semantic** (an example that parses and runs but
|
||||||
|
misrepresents the prose — e.g. `create_table`). The guard enforces only
|
||||||
|
the **syntactic floor**: examples parse in their mode. It backstops
|
||||||
|
future typos/clause-drift but cannot police meaning. Semantic correctness
|
||||||
|
of the current corpus rests on this session's review (recorded in the
|
||||||
|
commit + requirements.md H2). A stronger-but-brittler option was offered
|
||||||
|
to the user and **not built pending their call**: per-form assertions
|
||||||
|
that each example resolves to the *expected command shape* (e.g.
|
||||||
|
create_table → single-column PK). `hint.err.*` examples are fix-recipe
|
||||||
|
prose, not runnable, so they're verified by review only — inherent.
|
||||||
|
|
||||||
|
## §5. Next session — start here
|
||||||
|
|
||||||
|
The hint corpus is now trustworthy. Open roadmap (verify against the CI
|
||||||
|
merge first, per handoff-70 §5):
|
||||||
|
|
||||||
|
1. **Push** (your step) — this commit + the still-unpushed backlog from
|
||||||
|
handoffs 70/71 (the CI merge + all of H2).
|
||||||
|
2. **#35 (cargo fmt gate)** — the natural pairing with the merged CI; the
|
||||||
|
user wanted it done once, before first publication. The tree is **not**
|
||||||
|
fmt-clean (~1800 pre-existing diffs).
|
||||||
|
3. Other `requirements.md` open items: **TT4** PTY tier-4 (unwired),
|
||||||
|
**I1** multi-line input, **I5/B3** in-flight cancellation, **V4**
|
||||||
|
session journal (own ADR), **TU1** tutorial system (own ADR).
|
||||||
|
4. Hint follow-ups if wanted: **#37** clause-concept hints, **#38**
|
||||||
|
diagnostic route + `diagnostic.*` blocks, **#36** `help` advanced-SQL.
|
||||||
|
|
||||||
|
## §6. How to take over
|
||||||
|
|
||||||
|
1. Read handoffs 70 → 71 → 72, `CLAUDE.md`, `docs/requirements.md`.
|
||||||
|
2. Confirm green: `cargo test` (**2500 / 1 ignored**) + `cargo clippy
|
||||||
|
--all-targets` (clean).
|
||||||
|
3. For anything in the `hint` area, read **ADR-0053** first. For the
|
||||||
|
corpus, `src/friendly/strings/en-US.yaml` (`hint.cmd.*` / `hint.err.*`)
|
||||||
|
is the content; the guard in `src/dsl/grammar/mod.rs` is the regression
|
||||||
|
net.
|
||||||
|
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,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,137 @@
|
|||||||
|
# Session handoff — 2026-06-18 (74)
|
||||||
|
|
||||||
|
Large session. Continues from handoff-73 (Ctrl-G demo alias). This one ran
|
||||||
|
the **road to public availability** end to end: a post-merge doc
|
||||||
|
reconciliation, then versioning, installers, crates.io/binstall, the
|
||||||
|
`cargo fmt` gate, and an actual **`v0.2.0` release that is now live on
|
||||||
|
crates.io**. Three new main-sequence ADRs (0054/0055/0056), one CI-ADR
|
||||||
|
amendment, issue **#35 closed**.
|
||||||
|
|
||||||
|
## §1. State
|
||||||
|
|
||||||
|
**Branch `main`.** **2509 pass / 0 fail / 1 ignored** (the long-standing
|
||||||
|
`friendly` doctest); **clippy clean**; **`cargo fmt --check` clean** (the
|
||||||
|
tree is now stock-rustfmt formatted, and CI gates it). `rdbms-playground
|
||||||
|
--version` → `rdbms-playground 0.2.0`.
|
||||||
|
|
||||||
|
**Released:** **`v0.2.0`** — Gitea release with all six D1 targets
|
||||||
|
(Linux/Windows via `release.yaml` on the tag; macOS via the dispatched
|
||||||
|
`release-macos.yaml`, **ad-hoc-signed**). **Published to crates.io**
|
||||||
|
(`cargo install rdbms-playground` and `cargo binstall rdbms-playground`
|
||||||
|
both user-verified).
|
||||||
|
|
||||||
|
**Push state:** earlier commits were pushed (they triggered CI/releases).
|
||||||
|
The **most recent commits are unpushed** and matter:
|
||||||
|
- `3c87dbb` macOS nix-prune fix (takes effect next macOS dispatch).
|
||||||
|
- `d3af1c4` + `8ebe213` the manual `publish.yaml` workflow — ADR-0056 in
|
||||||
|
the first, the **workflow file itself in the second** (`git commit -am`
|
||||||
|
had skipped the new untracked file).
|
||||||
|
- this handoff.
|
||||||
|
Push `main` to land them. (Push is the user's step.)
|
||||||
|
|
||||||
|
## §2. What shipped (commits `628b250`→`d3af1c4`)
|
||||||
|
|
||||||
|
- **`628b250` doc reconciliation** after the CI + website branch merges:
|
||||||
|
rewrote CLAUDE.md's stale repo-layout tree; added a Website subproject
|
||||||
|
note; fixed the CI note; **gitignored `.wrangler/` + `.vscode/`** and
|
||||||
|
removed a tracked `website/.vscode/`; updated requirements (D1 macOS
|
||||||
|
runtime-verified, DOC1 canonical-docs-on-website) + ADR-ci-003.
|
||||||
|
- **`c30a611` ADR-0054 — version surfaces.** `--version`/`-V` + an in-app
|
||||||
|
**`version`** command, both reading `CARGO_PKG_VERSION` via one
|
||||||
|
`cli::version_text()`. A **release-CI guard** fails the release unless
|
||||||
|
the `v*` tag equals `v<Cargo.toml version>`.
|
||||||
|
- **`ef99e6c` ADR-0055 — `scripts/install.sh`** (curl|sh, POSIX,
|
||||||
|
shellcheck-clean, checksum-verified, `~/.local/bin`). Verified
|
||||||
|
end-to-end against the live release. **`install.ps1`** (Windows
|
||||||
|
`irm|iex`) added later (`e9606b5`) — **written but untested here** (no
|
||||||
|
PowerShell on this box; validate on Windows).
|
||||||
|
- **`e9606b5` ADR-0056 — crates.io + binstall prep.** Publish-ready
|
||||||
|
Cargo.toml (dropped `publish=false`; homepage/keywords/categories/
|
||||||
|
`exclude`); `README.md`; `LICENSE-MIT`/`LICENSE-APACHE` (dual, © Lazy
|
||||||
|
Evaluation Ltd) + `CONTRIBUTING.md` (inbound=outbound); the
|
||||||
|
`[package.metadata.binstall]` block with per-target overrides
|
||||||
|
(linux-gnu→musl, windows-msvc→gnu/gnullvm; macOS direct).
|
||||||
|
- **`41b7e9a` + `ec3c7c3` — #35 fmt gate.** One mechanical `cargo fmt`
|
||||||
|
(stock defaults, 102 files, behaviour-preserving) recorded in
|
||||||
|
`.git-blame-ignore-revs`; `ci.yaml` now gates `fmt --check` (ADR-ci-002
|
||||||
|
Amendment 1). **Closes #35.**
|
||||||
|
- **`88830ed`+`bd5be5e` — v0.2.0 bump + the guard bug.** The first
|
||||||
|
`release.yaml` run **failed** at the version guard: it piped `nix
|
||||||
|
develop -c cargo metadata` to node, but the **flake devShell prints a
|
||||||
|
banner to stdout**, corrupting the JSON. Fixed to a toolchain-free
|
||||||
|
`grep -m1 '^version = ' Cargo.toml`. The `v0.2.0` tag was re-pointed
|
||||||
|
(Option A) to the fix commit; re-run went green.
|
||||||
|
- **`3c87dbb` — macOS nix-prune fix.** The prune step's profile dir
|
||||||
|
(`~/.cache/rdbms-ci`) didn't exist, so `nix develop --profile` errored
|
||||||
|
(swallowed by `|| true`) → the gc-root was never created → the whole
|
||||||
|
toolchain (~3.8 GiB) was deleted **and re-downloaded every run**. Added
|
||||||
|
`mkdir -p` + dropped the `|| true`. Diagnosed from the run-74 log via
|
||||||
|
`tea actions runs logs 74`.
|
||||||
|
- **`d3af1c4` — manual `publish.yaml`.** `workflow_dispatch` + `tag`
|
||||||
|
input (mirrors `release-macos.yaml`). Idempotent `crates-io` job
|
||||||
|
(crates.io API pre-check + `cargo publish` backstop), independent jobs
|
||||||
|
so Scoop/Homebrew/winget slot in later. ADR-0056 Amendment 1.
|
||||||
|
|
||||||
|
## §3. Live vs manual vs parked
|
||||||
|
|
||||||
|
- **Automated on a `v*` tag:** `release.yaml` builds + publishes the four
|
||||||
|
Linux/Windows targets (+ fmt/clippy/test gate).
|
||||||
|
- **Manual `workflow_dispatch`:** `release-macos.yaml` (mac binaries —
|
||||||
|
intermittent runner) and `publish.yaml` (crates.io now; more registries
|
||||||
|
later). Run them once the tag's build is up.
|
||||||
|
- **Parked (user decisions):**
|
||||||
|
- **macOS Developer-ID signing.** The pipeline **ad-hoc-signs**
|
||||||
|
(`codesign --sign -`). The user's `Apple Development` cert is the
|
||||||
|
**wrong type** — distribution needs **`Developer ID Application`** +
|
||||||
|
**notarization** (App Store Connect API key recommended). Fine for
|
||||||
|
`curl|sh` (no quarantine); matters for browser downloads. Details in
|
||||||
|
`docs/plans/20260616-public-availability.md`.
|
||||||
|
- **Remaining D3:** Scoop (`lazyeval` bucket), Homebrew (`lazyeval`
|
||||||
|
tap), winget (komac on Linux CI, or manual PR) — each a sibling job
|
||||||
|
in `publish.yaml` + a manifest repo.
|
||||||
|
|
||||||
|
## §4. Immediate next steps
|
||||||
|
|
||||||
|
1. **Push `main`** (lands `3c87dbb` + `d3af1c4`).
|
||||||
|
2. **Add the `CARGO_REGISTRY_TOKEN` secret** (crate-scoped,
|
||||||
|
`publish-update`) so `publish.yaml` works: `tea actions secrets create
|
||||||
|
CARGO_REGISTRY_TOKEN` (paste at prompt) or the Gitea UI.
|
||||||
|
3. **Smoke-test `publish.yaml`:** dispatch it for `v0.2.0` — it should
|
||||||
|
**idempotently skip** ("already on crates.io"), exercising the path
|
||||||
|
risk-free.
|
||||||
|
4. The release ritual going forward (ADR-0054): bump `Cargo.toml` →
|
||||||
|
commit → tag `v<x.y.z>` → push tag (Linux/Windows release builds) →
|
||||||
|
dispatch `release-macos` → dispatch `publish`.
|
||||||
|
|
||||||
|
## §5. Gotchas learned (don't relearn the hard way)
|
||||||
|
|
||||||
|
- **The flake devShell prints a banner to stdout** — never pipe `nix
|
||||||
|
develop -c <cmd>` into a parser. Read Cargo.toml directly, etc.
|
||||||
|
- **Workflow-file source differs by trigger:** a **tag**-triggered run
|
||||||
|
(`release.yaml`) uses the workflow **at the tagged commit**; a
|
||||||
|
**`workflow_dispatch`** run (`release-macos`/`publish`) uses the
|
||||||
|
**default branch** (`main`). So fixing a dispatched workflow only needs
|
||||||
|
a `main` push; fixing a tag-triggered one needs the tag re-pointed.
|
||||||
|
- **Version vs tag:** `Cargo.toml` is bare `0.2.0`; the git tag is
|
||||||
|
`v0.2.0`; the guard checks `tag == "v" + version`; binstall `pkg-url`
|
||||||
|
spells `v{ version }`.
|
||||||
|
- **CI logs are reachable** via `tea actions runs logs <id>` (and `tea
|
||||||
|
actions runs list --output tsv`). Use it instead of guessing from a
|
||||||
|
step name.
|
||||||
|
- **crates.io API needs a descriptive User-Agent** (403 without one).
|
||||||
|
|
||||||
|
## §6. How to take over
|
||||||
|
|
||||||
|
1. Read handoffs 72 → 73 → 74, `CLAUDE.md`, `docs/requirements.md`, and
|
||||||
|
**`docs/plans/20260616-public-availability.md`** (the GA roadmap with
|
||||||
|
all decisions + parked items).
|
||||||
|
2. Confirm green: `cargo test` (**2509 / 1 ignored**), `cargo clippy
|
||||||
|
--all-targets`, `cargo fmt --check`.
|
||||||
|
3. ADRs for this arc: **0054** (versioning), **0055** (installer),
|
||||||
|
**0056** (crates.io/binstall + the publish workflow); CI side
|
||||||
|
**ADR-ci-002 Amendment 1** (fmt gate), **ADR-ci-003** (release matrix
|
||||||
|
+ macOS).
|
||||||
|
4. Workflow unchanged: phased, test-first, `/runda` + DA before commits,
|
||||||
|
ADR amendment + README index-upkeep for decided-area changes, confirm
|
||||||
|
commit messages, never push.
|
||||||
|
5. Consider a `cargo sweep` at this milestone (`target/` grows).
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Session handoff — 2026-06-21 (75)
|
||||||
|
|
||||||
|
Continues from handoff-74 (v0.2.0 live on crates.io). This session **finished
|
||||||
|
the D3 package-manager rollout** — Scoop, Homebrew, and winget — plus the
|
||||||
|
Windows `install.ps1` fixes, and included a deep **presigned-URL / Homebrew**
|
||||||
|
debugging saga that's fixed by a server-side Caddy rule. Five commits; one
|
||||||
|
new ADR amendment arc (ADR-0056 Amendments 2–4).
|
||||||
|
|
||||||
|
## §1. State
|
||||||
|
|
||||||
|
**Branch `main`.** **No crate (Rust) code changed this session** — only
|
||||||
|
`.gitea/workflows/`, `scripts/`, and `docs/`. So the `cargo test` baseline
|
||||||
|
from handoff-74 (**2509 pass / 0 fail / 1 ignored**, clippy + `fmt --check`
|
||||||
|
clean) stands unchanged by construction; not re-run (nothing in the build
|
||||||
|
graph moved). New coverage this session is **`scripts/test-package-renders.sh`**
|
||||||
|
(shellcheck-clean, green), now gated on every push by a new **`ci.yaml`
|
||||||
|
`manifests` job** (bash + node; ruby-absent in CI degrades gracefully).
|
||||||
|
|
||||||
|
**Commits this session** (`cabc813`→`0208c67`; the workflow-bearing ones were
|
||||||
|
pushed, since the live `publish` dispatches + raw-URL installs the user tested
|
||||||
|
needed them on `main`):
|
||||||
|
- `42b40bc` install.ps1 → Windows PowerShell 5.1 compat
|
||||||
|
- `c0531aa` install.ps1 → immediate-use PATH + honest messaging
|
||||||
|
- `6d54c1e` Scoop + Homebrew jobs (D3 §3b/§3c)
|
||||||
|
- `6bb2288` Scoop/Homebrew validated + Caddy fix recorded (Amendment 3)
|
||||||
|
- `0208c67` winget job via komac (D3 §3d, Amendment 4)
|
||||||
|
|
||||||
|
**External / infra state created this session (NOT all in the repo):**
|
||||||
|
- **Caddy Tier-A HEAD rule on the Gitea server** — *load-bearing for Homebrew*,
|
||||||
|
lives in the Gitea edge config, **not** this repo (see §4). If lost on a
|
||||||
|
server rebuild, brew 403s again.
|
||||||
|
- **Gitea:** `lazyeval` org with repos **`scoop-bucket`** + **`homebrew-tap`**;
|
||||||
|
**`lazyeval-ci`** Gitea bot user (org team, Write to those repos);
|
||||||
|
**`LAZYEVAL_PKG_TOKEN`** secret on `oli/rdbms-playground`.
|
||||||
|
- **GitHub:** **`lazyeval-ci`** bot account; classic `public_repo` PAT →
|
||||||
|
**`WINGET_GITHUB_TOKEN`** secret on `oli/rdbms-playground`; fork
|
||||||
|
**`lazyeval-ci/winget-pkgs`**.
|
||||||
|
- **winget bootstrap PR [#391335](https://github.com/microsoft/winget-pkgs/pull/391335)**
|
||||||
|
submitted, in Microsoft review.
|
||||||
|
- **komac 2.16.0** installed locally at `~/.local/bin/komac`.
|
||||||
|
|
||||||
|
## §2. D3 — the package-manager set (the session's throughline)
|
||||||
|
|
||||||
|
| Channel | State |
|
||||||
|
| --- | --- |
|
||||||
|
| crates.io / `cargo binstall` | **live** (handoff-74) |
|
||||||
|
| `install.sh` / `install.ps1` | **live** — PS1 validated on ARM Windows 11 (5.1 + 7.6) |
|
||||||
|
| Scoop (`lazyeval/scoop-bucket`) | **live + validated** (install + run) |
|
||||||
|
| Homebrew (`lazyeval/homebrew-tap`) | **live + validated** — needs the Caddy rule (§4) |
|
||||||
|
| winget (`LazyEvaluation.RdbmsPlayground`) | **wired**; PR #391335 awaiting Microsoft merge |
|
||||||
|
|
||||||
|
How Scoop/Homebrew/winget are wired: sibling jobs in
|
||||||
|
`.gitea/workflows/publish.yaml` (manual `workflow_dispatch`, `tag` input),
|
||||||
|
each idempotent + independent. Scoop/Homebrew render **dependency-free bash**
|
||||||
|
manifests (`scripts/render-{scoop-manifest,homebrew-formula}.sh`) from the
|
||||||
|
release `.sha256` sidecars and push to the org repos via `LAZYEVAL_PKG_TOKEN`.
|
||||||
|
winget runs `komac update --submit` to PR `microsoft/winget-pkgs`, guarded
|
||||||
|
against duplicate PRs. ADR-0056 **Amendments 2–4** carry the full design.
|
||||||
|
|
||||||
|
## §3. The Homebrew 403 saga + Caddy fix (don't lose this)
|
||||||
|
|
||||||
|
Homebrew `brew install` 403'd while every other tool worked. Root cause,
|
||||||
|
reproduced: Gitea (≥1.25; here 1.26.2) **303-redirects release downloads to a
|
||||||
|
method-bound AWS-SigV4 presigned S3 URL** (OVH), signed for the *incoming*
|
||||||
|
request's HTTP verb. Homebrew **resolves with a HEAD, captures the returned
|
||||||
|
HEAD-signed URL, then runs the download GET against it** → GET-on-a-HEAD-signed
|
||||||
|
URL → 403. (`GET on HEAD-resolved = 403`, `HEAD on HEAD-resolved = 200`,
|
||||||
|
`GET on GET-resolved = 200`.) GET-only tools (install.sh/ps1, binstall, curl)
|
||||||
|
are unaffected. This is a **Homebrew defect** (reusing an ephemeral
|
||||||
|
method-scoped credential as a durable locator); Gitea's per-request signing is
|
||||||
|
correct, and SigV4 *cannot* sign one URL for two verbs. `SERVE_DIRECT=false`
|
||||||
|
would fix it but was **declined** (don't reshape storage for one client).
|
||||||
|
|
||||||
|
**Fix (deployed, "Tier A"):** a Caddy rule answering **HEAD on
|
||||||
|
`…/releases/download/…` directly** (200, no redirect, no body) so brew's
|
||||||
|
resolve records the *original* URL and its download GET redirects fresh →
|
||||||
|
GET-signed → 200. GET untouched; a HEAD carries no payload so nothing can
|
||||||
|
break (a HEAD-probing download manager at most loses a progress-bar size).
|
||||||
|
Reference Caddyfile is in **ADR-0056 Amendment 3**.
|
||||||
|
|
||||||
|
## §4. Immediate next steps
|
||||||
|
|
||||||
|
1. **winget PR #391335:** watch validation — the one likely speed bump is an
|
||||||
|
**AV/SmartScreen manual-review label** (unsigned binary); usually clears.
|
||||||
|
Once a moderator merges, winget is live.
|
||||||
|
2. **Post-merge check (Windows):** `winget install LazyEvaluation.RdbmsPlayground`,
|
||||||
|
confirm the command is **`rdbms-playground`** (it is — a direct single-exe
|
||||||
|
portable takes its PATH alias from `Commands[0]`, which komac set; see §5).
|
||||||
|
3. After merge, future releases are hands-off: the CI `winget` job's
|
||||||
|
`komac update` carries each version (alias inherited from the merged
|
||||||
|
manifest). The release ritual is unchanged from ADR-0054, with the
|
||||||
|
`publish` dispatch now also doing Scoop/Homebrew/winget.
|
||||||
|
|
||||||
|
## §5. Gotchas learned (don't relearn the hard way)
|
||||||
|
|
||||||
|
- **The Caddy HEAD rule is server-side, not in this repo.** Homebrew (and any
|
||||||
|
HEAD-then-GET client) depends on it. Record/back it up.
|
||||||
|
- **GitHub tokens:** komac needs a **classic `public_repo`** PAT — **fine-grained
|
||||||
|
tokens can't open the cross-fork PR** (komac #310: the PR is created on the
|
||||||
|
*target* repo you don't administer, which the fine-grained model can't
|
||||||
|
express). A classic token can't be repo-scoped, so it lives on a **dedicated
|
||||||
|
bot** (`lazyeval-ci`) to bound the blast radius.
|
||||||
|
- **komac fork must pre-exist:** `komac new` auto-fork *races* on winget-pkgs
|
||||||
|
(huge repo) → *"Could not resolve to a Repository 'lazyeval-ci/winget-pkgs'"*.
|
||||||
|
Fork manually first (`gh repo fork microsoft/winget-pkgs --clone=false`),
|
||||||
|
wait, then re-run. Pre-fill `komac new` flags to skip the long interactive
|
||||||
|
prompt parade.
|
||||||
|
- **Direct portable alias = `Commands[0]`, NOT `PortableCommandAlias`.** The
|
||||||
|
latter is **archive/nested-portable-only**; adding it to a bare-exe portable
|
||||||
|
is wrong (komac correctly omits it). The "Commands" prompt → `rdbms-playground`
|
||||||
|
is the on-PATH command. Verified vs winget-cli logic + the 1.6.0 schema.
|
||||||
|
- **install.ps1 / Windows PowerShell 5.1** (the in-box shell — PS7 is opt-in):
|
||||||
|
arch via `PROCESSOR_ARCHITECTURE` env (not `RuntimeInformation::OSArchitecture`,
|
||||||
|
which is absent under 5.1's .NET-Framework facade + StrictMode); force TLS 1.2;
|
||||||
|
`-UseBasicParsing`. Also update `$env:Path` in-session (the persisted User PATH
|
||||||
|
only reaches *new* processes; "restart your shell" was wrong — sign-out/in or
|
||||||
|
the in-session update).
|
||||||
|
- **Gitea release downloads go through OVH S3 presigned URLs** — method-bound,
|
||||||
|
300 s expiry. Never assume HEAD-resolve-then-GET works against them.
|
||||||
|
|
||||||
|
## §6. Parked / deferred (user decisions)
|
||||||
|
|
||||||
|
- **macOS Developer-ID signing + notarization** — pending the Apple account →
|
||||||
|
Organization conversion. Ad-hoc signing covers all package-manager paths
|
||||||
|
(brew-installed binary *runs* on Apple Silicon); Developer-ID only matters for
|
||||||
|
browser-download Gatekeeper trust.
|
||||||
|
- **Windows code signing (Trusted Signing)** — not required to *ship* winget
|
||||||
|
(portable; only MSIX needs signing). Unsigned can earn an AV/SmartScreen
|
||||||
|
manual-review label + a user-run warning. **Lazy Evaluation Ltd (since 2012)
|
||||||
|
clears Trusted Signing's 3-year-org bar** when the user wants to remove the
|
||||||
|
warning (individual onboarding is currently paused; org path is open).
|
||||||
|
- **Release notes / CHANGELOG** — raised this session, not done. If wanted:
|
||||||
|
a `CHANGELOG.md` and/or populated Gitea release bodies, then the CI `winget`
|
||||||
|
job can pass komac `--release-notes-url …/releases/tag/$TAG`.
|
||||||
|
|
||||||
|
## §7. How to take over
|
||||||
|
|
||||||
|
1. Read handoffs 73 → 74 → 75, `CLAUDE.md`, `docs/requirements.md` (D1/D3),
|
||||||
|
and **ADR-0056 (esp. Amendments 1–4)** + the GA plan
|
||||||
|
`docs/plans/20260616-public-availability.md`.
|
||||||
|
2. Workflow unchanged: phased, test-first, `/runda` + DA before commits, ADR
|
||||||
|
amendment + README index-upkeep for decided-area changes, confirm commit
|
||||||
|
messages, never push.
|
||||||
|
3. If `cargo test` is needed, the baseline is **2509 / 1 ignored** (handoff-74).
|
||||||
|
4. Consider a `cargo sweep` at this milestone (`target/` grows).
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Website-branch handoff — 2026-06-21 (website-3)
|
||||||
|
|
||||||
|
Third handoff for the **website** work (separate sequence from `main`'s
|
||||||
|
`YYYYMMDD-handoff-NN.md`). Read **website-2** (2026-06-11) first for the
|
||||||
|
scaffolding, cast pipeline, and the no-advertising constraint; this note covers
|
||||||
|
everything since, and — importantly — the state has moved on (see §1).
|
||||||
|
|
||||||
|
## §1. State (read this first)
|
||||||
|
|
||||||
|
- **Branch:** `website`. **HEAD `dff7841`.** Working tree **clean**.
|
||||||
|
- **Pushed:** `origin/website` == `dff7841` (this session's work is pushed).
|
||||||
|
- **Already merged into `main`:** `dff7841` is an ancestor of `main` — every
|
||||||
|
website change below is in `main`. **`main` has since advanced ~20 commits**
|
||||||
|
(now `56e3456`, handoff 75) with **v0.2.0 + install/packaging** work. So
|
||||||
|
**`website` is ~20 commits behind `main`.**
|
||||||
|
- **Consequence for the next session:** start by **merging `main` into
|
||||||
|
`website` again** (or rebranching `website` off `main`). There is real new
|
||||||
|
user-facing surface to document — see §4.
|
||||||
|
- **Agent limitation:** this machine's SSH key for `git.lazyeval.net` was **not
|
||||||
|
available to the agent** — `git fetch`/`push` fail from inside the session.
|
||||||
|
**Push and fetch are the user's steps.** Local merges work fine.
|
||||||
|
|
||||||
|
## §2. What shipped this session (all in `main` now)
|
||||||
|
|
||||||
|
The website lives in **`website/`**; living style guide **`website/STYLE.md`**;
|
||||||
|
decisions **`docs/website/adr/20260604-adr-website-001.md`**.
|
||||||
|
|
||||||
|
**CI / deployment — the big one (ADR-website-001 §4):**
|
||||||
|
- **`.gitea/workflows/website.yaml`** — on a push to `main` or `website` that
|
||||||
|
touches `website/**`, build the Astro site with pnpm and `wrangler pages
|
||||||
|
deploy website/dist` to the Cloudflare **Pages project `relplay`** (Direct
|
||||||
|
Upload, no Git integration). `--branch` selects environment vs the project's
|
||||||
|
production branch (`main`): **`main` → production (`relplay.org`)**,
|
||||||
|
**`website` → preview (`website.relplay.pages.dev`)**.
|
||||||
|
- Pure-Node build (the `.cast` files are committed, so no cargo). Runs on the
|
||||||
|
bare `ci-public` runner (node present; pnpm via corepack, pinned by
|
||||||
|
`package.json`'s `packageManager: pnpm@10.30.3`).
|
||||||
|
- **Secrets (set in repo Actions settings):** `CLOUDFLARE_API_TOKEN` (Pages:
|
||||||
|
Edit) + `CLOUDFLARE_ACCOUNT_ID`. Pages project created with production branch
|
||||||
|
= `main`.
|
||||||
|
- **`ci.yaml` gate** now skips website-only pushes (`paths-ignore` += `website/**`,
|
||||||
|
`.gitea/workflows/website.yaml`).
|
||||||
|
- **`staging.relplay.org`** (optional): attach the custom domain to the `website`
|
||||||
|
branch alias — add it in the Pages dashboard, then point its **proxied**
|
||||||
|
CNAME at `website.relplay.pages.dev` (external/unproxied DNS falls through to
|
||||||
|
production). Cloudflare Pages maps branches to **subdomains, not sub-folders**.
|
||||||
|
|
||||||
|
**Content updates (from two `main` merges that landed seed/readline/hint/etc.):**
|
||||||
|
- **Seed page** (`reference/generating-sample-data.mdx`) refreshed for **#33/#34**:
|
||||||
|
year-as-int columns (`*_year`/`published` → plausible years), built-in value
|
||||||
|
sets (`priority`/`severity`/`rating`), advisory now **status-only**. All output
|
||||||
|
blocks re-captured; the seed cast reflects it.
|
||||||
|
- **`hint` feature** (ADR-0053) documented: `getting-help.md` → **`.mdx`** with a
|
||||||
|
"Hints" section (F1 = tier-3 live-input hint; `hint` command = last-error hint)
|
||||||
|
+ the real rendered block; `the-assistive-editor.mdx` points at F1.
|
||||||
|
- **Readline keys (#29)** + **cross-mode history recall (#30)** documented on
|
||||||
|
`the-assistive-editor.mdx`; "Planned" narrowed to multi-line entry only.
|
||||||
|
- **Landing** (`index.mdx`): the redundant plain **`<h1>` "RDBMS Playground"** is
|
||||||
|
hidden (kept for SEO/a11y via a **title-only hero**, landing-scoped in
|
||||||
|
`global.css`); the **Wordmark + tagline + buttons** render in the body so the
|
||||||
|
lockup sits where the title was and content rises above the fold (it was nearly
|
||||||
|
hidden on an iPad).
|
||||||
|
- **All 10 casts re-recorded** + a **new `hint` cast** (11 total) against the
|
||||||
|
current app.
|
||||||
|
|
||||||
|
**Casts — the F1 trick (important for future hint/keybinding casts):**
|
||||||
|
- autocast **cannot send F1** (or arrows/function keys — escape sequences split
|
||||||
|
by the per-key delay). Solution shipped on `main`: **ADR-0047 Amendment 1** —
|
||||||
|
in `--demo` mode **Ctrl-G aliases F1** and **badges AS `[F1]`**, so a cast looks
|
||||||
|
like a real F1 press. The cast generator gained `CtrlG: '^G'`.
|
||||||
|
- The **hint cast** uses it realistically: type `add column `, pause, **F1**
|
||||||
|
(Ctrl-G) mid-command to recall the syntax, finish the command from the example,
|
||||||
|
then later a failed command + `hint` for the error.
|
||||||
|
|
||||||
|
**Hint-vs-docs audit (user-requested):** one genuine conflict found —
|
||||||
|
`hint.cmd.create_table.example` declared a 3-column compound PK; **fixed upstream**
|
||||||
|
(`5a37437`, now `create table Customers with pk id(serial)`). Everything else
|
||||||
|
(export/import, indexes, constraints, DML, SQL forms, error hints, terminology)
|
||||||
|
was consistent.
|
||||||
|
|
||||||
|
## §3. Verification status
|
||||||
|
|
||||||
|
- `cd website && pnpm build` → **clean, 27 pages**. Forbidden-terms grep clean
|
||||||
|
(`DSL`/`SQLite`/`STRICT`/`rusqlite`/`PRAGMA`). Output-table box-drawing intact;
|
||||||
|
anchors resolve; hint cast content verified (`[F1]` badge + both hint blocks).
|
||||||
|
- **NOT done — needs a human visual pass:** the new **landing spacing/aesthetics**
|
||||||
|
(couldn't be eyeballed by the agent) and the **11 re-recorded casts** (verified
|
||||||
|
programmatically only). Do this in staging/production before relying on them.
|
||||||
|
|
||||||
|
## §4. NEXT WORK — priority order
|
||||||
|
|
||||||
|
1. **Re-merge `main` into `website`** (it's ~20 behind) — or rebranch off `main`.
|
||||||
|
2. **Reconcile the installation / getting-started docs with what `main` actually
|
||||||
|
shipped (v0.2.0):** the website branch's `installation.md` predates several
|
||||||
|
real install methods now live —
|
||||||
|
- **crates.io v0.2.0** (`cargo install rdbms-playground`, `cargo binstall`),
|
||||||
|
- the **curl | sh installer** (ADR-0055),
|
||||||
|
- **Windows `install.ps1`** (`fix(install)` commits),
|
||||||
|
- **package managers**: Scoop / Homebrew / **winget** (ADR-0056; winget via
|
||||||
|
komac). `installation.md` mentions brew/scoop but **not** winget, the
|
||||||
|
curl|sh installer, or binstall — reconcile against the shipped reality
|
||||||
|
(check ADR-0055/0056 + handoffs 74/75 + the `publish.yaml` workflow).
|
||||||
|
- There's also `--version`/`-V` + an in-app `version` command (ADR-0054) — a
|
||||||
|
line on the command-line-options page may be warranted.
|
||||||
|
3. **Visual sweep** (§3): landing + casts; final caption review (website-2 §2).
|
||||||
|
4. **Carryover from website-2:** light/dark player theme (deferred), open STYLE
|
||||||
|
decisions (#7/#8 mostly resolved; recheck), output-pane-scroll cast still
|
||||||
|
blocked (PgUp/PgDn unsendable — would need a typeable scroll key like #24).
|
||||||
|
|
||||||
|
## §5. Process pins
|
||||||
|
|
||||||
|
- **Commits:** user-confirmed (show the message first), **no AI attribution**,
|
||||||
|
**append-only** (no amend/rebase/force-push). **Push/fetch are the user's
|
||||||
|
steps** (and the agent can't reach the remote anyway — §1).
|
||||||
|
- **Ground every page in source** (`src/dsl/*`, `src/friendly/strings/en-US.yaml`,
|
||||||
|
the ADRs) — not `requirements.md` markers. No engine name, no "DSL" in copy.
|
||||||
|
- **Output blocks captured from the real app**, never hand-drawn (STYLE.md). The
|
||||||
|
capture recipe (throwaway in-crate/`it` test → render → paste → delete) and the
|
||||||
|
cast pipeline (`pnpm casts [name]`, needs `../target/debug`) are in website-2.
|
||||||
|
- **Cast stance (revised 2026-06-12):** default to a cast; justify its *absence*.
|
||||||
|
- **Issues** via `tea` (repo `oli/rdbms-playground` on `git.lazyeval.net`;
|
||||||
|
append `< /dev/null` + `timeout 30`). This session filed **#33** (year ints)
|
||||||
|
and **#34** (enum value sets) — both **implemented** on `main` now.
|
||||||
|
|
||||||
|
## §6. Footnote — a git oddity in this branch's history
|
||||||
|
|
||||||
|
`c84a640 "docs(website): document the hint feature with a cast"` is an **empty
|
||||||
|
rename commit** (a `git add` tooling slip staged only the `.md`→`.mdx` rename);
|
||||||
|
its actual content is in the **next** commit `028d324`. Harmless, left in place
|
||||||
|
per the append-only rule (user-approved). No action needed.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Session handoff — 2026-06-22 (76)
|
||||||
|
|
||||||
|
Continues from handoff-75 (D3 package managers). This session took up the
|
||||||
|
**regression-hardening** trio identified as "what's next" now that public
|
||||||
|
binaries ship: **TT4** (Tier-4 PTY tests), **NFR verification**, and a
|
||||||
|
**CHANGELOG** — plus a real palette accessibility fix the work uncovered.
|
||||||
|
|
||||||
|
## §1. State
|
||||||
|
|
||||||
|
**Branch `main`.** Commits this session (all on `main`, **not pushed** — push
|
||||||
|
is the user's step):
|
||||||
|
- `65eab71` `fix(theme)` — WCAG-AA palette fix + contrast/ΔE2000 gates +
|
||||||
|
`scripts/palette-preview.py`.
|
||||||
|
- `fd63de3` `test(tt4)` — Tier-4 PTY end-to-end suite (`tests/e2e_pty.rs`).
|
||||||
|
- `88204f2` `docs(tt4,nfr)` — ADR-0008 Amendment 1, ADR-0057, README index,
|
||||||
|
`requirements.md`, `CHANGELOG.md`, handoff-76, plan doc.
|
||||||
|
- `1ffe11c` `fix(tt4)` — drop the dead `pid` helper (macOS-only dead-code
|
||||||
|
warning the Linux gate can't see; found via the user's macOS run).
|
||||||
|
- `35ca108` `docs(tt5)` — macOS Tier-4 confirmation + Windows verification
|
||||||
|
stance (and the §7 below was added in a follow-up docs commit).
|
||||||
|
|
||||||
|
**Test baseline: 2519 passed / 0 failed / 1 ignored** (was 2509; +4 theme
|
||||||
|
gates, +6 e2e_pty). `clippy --all-targets -D warnings` + `fmt --check` clean.
|
||||||
|
Full suite verified green 3× under parallel load on Linux, and a full native
|
||||||
|
run on **macOS** (5 e2e_pty there — Linux-only RSS test cfg-skipped).
|
||||||
|
|
||||||
|
## §2. What shipped
|
||||||
|
|
||||||
|
**Palette accessibility (ADR-0057, commit `65eab71`).** Computing real WCAG
|
||||||
|
ratios while writing the gate **found two shipped defects** in v0.2.0's light
|
||||||
|
theme — `tok_string` (4.42:1) and `tok_flag` (3.15:1), below the 4.5:1 the
|
||||||
|
scheme promises — and two dark token pairs that were perceptually close
|
||||||
|
despite distinct hex. All four fixed (only `theme.rs` colours changed, 4
|
||||||
|
values). New gated tests in `src/theme.rs`:
|
||||||
|
- `all_text_colours_meet_wcag_aa_contrast` (≥4.5:1, both themes),
|
||||||
|
- `advanced_mode_border_meets_ui_contrast` (≥3:1; plain border exempt),
|
||||||
|
- `delta_e_2000_matches_reference_vectors` (CIEDE2000 validated vs Sharma data),
|
||||||
|
- `syntax_token_colours_are_perceptually_distinct` (ΔE2000 ≥15).
|
||||||
|
Dev tool **`scripts/palette-preview.py`** renders the live palette (truecolor
|
||||||
|
swatch + contrast + ΔE2000), supports `theme:key=HEX` overrides for previewing
|
||||||
|
changes.
|
||||||
|
|
||||||
|
**TT4 — Tier-4 PTY tests (ADR-0008 Amendment 1, commit `fd63de3`).**
|
||||||
|
`tests/e2e_pty.rs` drives the **real built binary** in a pseudo-terminal:
|
||||||
|
`portable-pty` 0.9 + `vt100` 0.16 (**`expectrl` dropped** — conflicting PTY
|
||||||
|
layer; replaced by a hand-rolled `wait_for` on the vt100 grid). 100×30, temp
|
||||||
|
`--data-dir`, serial, tight 3 s fail-fast waits. The four ADR-0008 flows all
|
||||||
|
green. Runs by default in `cargo test` → the Linux gate now exercises Tier 4
|
||||||
|
(**advances TT5**). PTY-in-container validated (openpty+spawn under Docker).
|
||||||
|
- **Gotchas baked into the harness:** read table presence from the Tables
|
||||||
|
**sidebar** region (the Output panel echoes commands, polluting whole-screen
|
||||||
|
matches); **pace commands to completion** (a command sent mid-rebuild
|
||||||
|
misparses — issue #39); simple-mode insert needs the `values` keyword and a
|
||||||
|
single-value insert needs `add column` (a 2nd col in `with pk …` makes a
|
||||||
|
**compound PK**).
|
||||||
|
|
||||||
|
**NFR verification (ADR-0057).** All seven NFRs formally verified:
|
||||||
|
- NFR-1/3 **measured** via the PTY harness — release figures **~29 ms** startup
|
||||||
|
/ **~10 MB** idle RSS (well under 500 ms / 50 MB). Harness tests gate generous
|
||||||
|
*debug*-binary bounds for gross-regression detection (tight gates declined as
|
||||||
|
flaky — user decision).
|
||||||
|
- NFR-5/7 **gated** (the contrast/ΔE2000 tests above).
|
||||||
|
- NFR-2 by architecture (worker thread ADR-0010 + separate input task).
|
||||||
|
- NFR-4/6 reviewer note (`[/]`).
|
||||||
|
|
||||||
|
**CHANGELOG.md** — Keep a Changelog + SemVer; `[Unreleased]` + `[0.2.0]`
|
||||||
|
(0.1.0 = pre-history). v0.2.0 tag = `bd5be5e`; the palette fix + package
|
||||||
|
managers + TT4 are post-tag → Unreleased.
|
||||||
|
|
||||||
|
## §3. Decisions taken with the user
|
||||||
|
|
||||||
|
- Palette: fix the two light contrast bugs **and** the two dark near-duplicates
|
||||||
|
(4 hex changes, approved after a rendered preview).
|
||||||
|
- Borders <3:1 → **accept + document** (decorative chrome).
|
||||||
|
- Work directly on `main`; ADR-0057 numbered now (not draft).
|
||||||
|
- NFR perf: measured + generously bounded, **not** tightly CI-gated.
|
||||||
|
- CHANGELOG depth: 0.2.0 + Unreleased only.
|
||||||
|
- Schema-cache finding → **file a Gitea issue, defer the fix** (see §4).
|
||||||
|
|
||||||
|
## §4. Open / follow-ups
|
||||||
|
|
||||||
|
- **Issue #39** (filed this session): simple-mode **Form-B insert**
|
||||||
|
(`insert into T values (…)`) submitted faster than the post-DDL **schema-cache
|
||||||
|
refresh** validates against stale schema and is misparsed as SQL. No
|
||||||
|
interactive-user impact (cast driver + tests pace); deferred fix. Repro +
|
||||||
|
diagnosis in the issue.
|
||||||
|
- **TT5 remaining:** only a **Windows execution runner** now (macOS + Tier-4
|
||||||
|
gaps closed this session). First real CI run is the final confirmation that
|
||||||
|
the PTY tests pass in the Gitea container (validated locally; very low risk).
|
||||||
|
- **Next focus is the open issues — see §7** (user direction 2026-06-22: clear
|
||||||
|
the open issues before resuming feature work). Larger features (V4 session
|
||||||
|
journal, TU1 tutorial) come after. The CHANGELOG can later feed
|
||||||
|
`--release-notes-url` into the CI winget job (handoff-75 §6).
|
||||||
|
|
||||||
|
## §5. Process pins
|
||||||
|
|
||||||
|
- Commits user-confirmed, no AI attribution, append-only, **push is the user's
|
||||||
|
step**.
|
||||||
|
- `/runda` + DA pass was run on both the plan and the implementation (it caught
|
||||||
|
the two light-theme contrast bugs and the #39 schema-cache behaviour — both
|
||||||
|
before finalizing).
|
||||||
|
- Consider a `cargo sweep` at this milestone (release build added ~ a GB).
|
||||||
|
|
||||||
|
## §6. Post-finalization (same session)
|
||||||
|
|
||||||
|
- **macOS fully verified.** A native `cargo test` on macOS was fully green —
|
||||||
|
1813 lib + **5 `e2e_pty`** + 500 integration + 200 typing. This **confirms
|
||||||
|
Tier 4 on macOS** (the 4 flows + startup; the Linux-only RSS probe is
|
||||||
|
`#[cfg]`-skipped, hence 5 not 6). Closes the runda "Tier-4 unverified on macOS"
|
||||||
|
item.
|
||||||
|
- **`fix(tt4)` `1ffe11c`** — the macOS run surfaced a `method `pid` is never
|
||||||
|
used` warning: `rss_kib` is Linux-only and was `pid`'s only caller, so off
|
||||||
|
Linux it was dead code. The Linux clippy gate can't catch this (pid *is* used
|
||||||
|
on Linux). Read `child.process_id()` inline; deleted the helper.
|
||||||
|
- **Cross-platform crate downloads off-target are normal** — `cargo` fetches the
|
||||||
|
whole locked graph (Windows ConPTY bindings, Unix libc/nix from `portable-pty`)
|
||||||
|
but only compiles host-target crates; nothing off-target is actually built.
|
||||||
|
- **TT5 / Windows decision (2026-06-22):** a permanent Windows CI runner stays
|
||||||
|
**open, no promises** — standing one up is more involved than the Linux/macOS
|
||||||
|
runners already in use. Interim: Windows builds ship every release (D1/D2) and
|
||||||
|
are **verified by running the suite on Windows by hand periodically** — the
|
||||||
|
same builds Windows users get, manually verified rather than per-push. The TT5
|
||||||
|
note was reworded to say this plainly and welcomingly; it stays `[/]` by
|
||||||
|
deliberate scope. (Virtualization options explored for a future runner are not
|
||||||
|
recorded here — they hinge on host specifics rather than project constraints.)
|
||||||
|
|
||||||
|
## §7. Next session — start with the open issues
|
||||||
|
|
||||||
|
Per user direction (2026-06-22), the next session should **work the open Gitea
|
||||||
|
issues before resuming feature work** (V4 journal, TU1 tutorial, etc.). There
|
||||||
|
are **four open** (`tea issues list --state open --limit 100`; read a body with
|
||||||
|
`tea issue <n> --fields body --output json < /dev/null | jq -r '.body'`, and
|
||||||
|
comments with `tea issue <n> --comments < /dev/null`):
|
||||||
|
|
||||||
|
| # | Label | One-line | This-session read on scope |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **#36** | enhancement | `help <sql-form>` shows no distinct content — the seven advanced-mode SQL nodes carry `help_id: None` (a dedup hack for the `help` list; `src/dsl/grammar/mod.rs:915-918`), so e.g. `help select` resolves to nothing. The parse-error/usage layer (ADR-0042/H1a) already distinguishes them; only the `help` command lags. | **Contained.** Good first pick — a real, bounded gap. Touches H3/`help`; an ADR amendment may apply. |
|
||||||
|
| **#37** | enhancement | Clause-concept hints — deeper teaching when the cursor sits inside a recognized *clause* (`on delete ⟨cascade\|set null\|restrict⟩`, the `create table` constraint slots, `with pk`, `1:n`/`m:n`), between tier-2's candidate list and the whole-form tier-3 block. Deferred extension of **ADR-0053** (H2). | **Medium scope, richest teaching value** — squarely on the pedagogy mission. Likely an ADR-0053 amendment. |
|
||||||
|
| **#38** | enhancement | Pre-submit-diagnostic F1 route + ~33 `diagnostic.*` tier-3 blocks. Needs a `class`/`message_key` field threaded through **every** diagnostic-creation site (walker + validators). Deferred from ADR-0053 Phase C. | **Broad mechanism change for the most marginal value** (tier-2 already surfaces these). The issue itself says so. **Decision needed:** do, defer, or close as wontfix — escalate to the user. |
|
||||||
|
| **#39** | bug | Simple-mode **Form-B insert** (`insert into T values (…)`) submitted faster than the post-DDL **schema-cache refresh** validates against stale schema → misparsed as SQL. Filed this session; repro + diagnosis (incl. `SchemaCache` at `src/completion.rs:53`, Form-B handling `src/dsl/walker/context.rs:155-157`) in the issue. | **Low impact** (no interactive-user hit; cast driver + tests pace). Fix is its own focused change (sequence the cache refresh with command execution, or validate against the authoritative schema). |
|
||||||
|
|
||||||
|
**Suggested order (a recommendation, not a mandate — confirm with the user):**
|
||||||
|
#36 (contained warm-up) → #37 (highest on-mission value) → #39 (bug; size the fix
|
||||||
|
first) → #38 (get the user's do/defer/close call before investing in the broad
|
||||||
|
threading change). All four are hint/help/parse-adjacent except #39; reading
|
||||||
|
**ADR-0053** (the contextual-hint design) first will orient #37 and #38.
|
||||||
|
|
||||||
|
No new labels needed (`bug`/`enhancement` cover them; ask the user before
|
||||||
|
creating any). Issue-tracker etiquette + `tea` gotchas are in the project
|
||||||
|
`CLAUDE.md` ("Issue tracking — Gitea via `tea`").
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Session handoff — 2026-06-22 (77)
|
||||||
|
|
||||||
|
Continues from handoff-76. Per the user's direction (handoff-76 §7: clear the
|
||||||
|
open Gitea issues before resuming feature work), this session took the **bug**
|
||||||
|
of the four open issues — **#39** (Form-B insert misparse against a stale schema
|
||||||
|
cache after fast DDL) — and fixed it end-to-end. Three open issues remain
|
||||||
|
(#36/#37/#38, all enhancements); see §3.
|
||||||
|
|
||||||
|
## §1. State
|
||||||
|
|
||||||
|
**Branch `main`.** Commits this session (on `main`, **not pushed** — push is the
|
||||||
|
user's step):
|
||||||
|
|
||||||
|
- `07575da` `fix(app)` — schema-refresh submission gate (#39) + Tier-1 and
|
||||||
|
Tier-4 PTY regression tests + ADR-0022 Amendment 8 + README index.
|
||||||
|
- this `docs(handoff-77)` commit — folds in the `CHANGELOG.md` `[Unreleased]`
|
||||||
|
Fixed bullet for #39 **and** a new **changelog-discipline rule** in project
|
||||||
|
`CLAUDE.md` (see §4).
|
||||||
|
|
||||||
|
**Test baseline: 2521 passed / 0 failed / 1 ignored** (was 2519; +1 lib test,
|
||||||
|
+1 e2e_pty test → e2e_pty now 7 on Linux). `clippy --all-targets -D warnings` +
|
||||||
|
`fmt --check` clean. The new PTY test was confirmed to **time out without the
|
||||||
|
fix** (genuine guard), green with it.
|
||||||
|
|
||||||
|
**Issue #39 is closed** (resolution comment + diagnosis recorded on the issue).
|
||||||
|
|
||||||
|
## §2. What shipped — issue #39
|
||||||
|
|
||||||
|
**Root cause.** `App::update` is pure-sync and validates submissions against
|
||||||
|
`App::schema_cache`. The runtime refreshes that cache **asynchronously** after
|
||||||
|
the worker applies a command — a `SchemaCacheRefreshed` event posted on the
|
||||||
|
**same FIFO channel as key events** (`runtime.rs:1591`, applied `app.rs`
|
||||||
|
`SchemaCacheRefreshed` arm). Under faster-than-human input (paste / script /
|
||||||
|
unpaced PTY) the next Enter is processed *before* the refresh lands, so a
|
||||||
|
simple-mode **Form-B insert** (`insert into T values (…)`, columns derived from
|
||||||
|
the cache) submitted right after `add column` sees the pre-DDL columns, its
|
||||||
|
arity can't match, and the friendly layer tags it *"trying to write SQL?"*. The
|
||||||
|
worker itself is never wrong (it runs commands serially); the bug was purely in
|
||||||
|
client-side pre-validation racing the refresh.
|
||||||
|
|
||||||
|
**Fix — submission gate (`src/app.rs`).** Two new private `App` fields:
|
||||||
|
`awaiting_schema_refresh: bool` + `held_submissions: VecDeque<(String,
|
||||||
|
EffectiveMode)>`. `dispatch_dsl` arms the flag on **every** `ExecuteDsl`
|
||||||
|
dispatch; while armed, a new DSL submission is **held** (queued in submission
|
||||||
|
order) rather than validated. The `SchemaCacheRefreshed` handler clears the flag
|
||||||
|
and **drains** the queue against the now-fresh cache, stopping as soon as a
|
||||||
|
drained command re-arms the gate (the rest then wait for *its* refresh — order
|
||||||
|
preserved). App-lifecycle commands route through `dispatch_app_command` *before*
|
||||||
|
`dispatch_dsl`, so `quit`/`help`/`load`/`undo`/`rebuild` are never held.
|
||||||
|
|
||||||
|
**Why arm on every dispatch, not just DDL** — it keeps at most one DSL command
|
||||||
|
in flight, so refreshes are strictly one-per-dispatch and in order, making the
|
||||||
|
boolean provably correct. Arming only on DDL would let a *preceding* non-DDL
|
||||||
|
command's refresh clear the gate early and drain a held insert against a
|
||||||
|
pre-DDL cache (a real corner case). Cost was one existing test
|
||||||
|
(`walking_skeleton::colon_escape_in_simple_mode_is_one_shot`) updated to model
|
||||||
|
the post-dispatch refresh — faithful, since it had been assuming it.
|
||||||
|
|
||||||
|
**Scope = interactive only.** The `replay` / history-log / startup
|
||||||
|
rebuild-from-text batch path already re-snapshots the schema **synchronously,
|
||||||
|
inline, before every line** (`run_replay` → `build_schema_cache`,
|
||||||
|
`runtime.rs:2514`), so it always had this ordering guarantee; the fix brings the
|
||||||
|
interactive path in line with it. No interactive-user impact (the gate clears in
|
||||||
|
ms); held input is never lost because the runtime sends a `SchemaCacheRefreshed`
|
||||||
|
after **every** dispatch, success or failure (the unconditional post-match block
|
||||||
|
in `spawn_dsl_dispatch`).
|
||||||
|
|
||||||
|
**Tests** (both verified red→green):
|
||||||
|
- `app::tests::form_b_insert_after_ddl_is_held_until_refresh_then_dispatched` —
|
||||||
|
Tier-1, deterministic: stale cache → submit DDL (arms) → submit insert (held,
|
||||||
|
no error) → deliver fresh `SchemaCacheRefreshed` → insert dispatches.
|
||||||
|
- `e2e_pty::back_to_back_insert_after_ddl_still_succeeds` — Tier-4 PTY, the
|
||||||
|
unpaced inverse of flow 3; asserts the insert's own `('Alice') ✓` echo.
|
||||||
|
|
||||||
|
**Docs.** ADR-0022 **Amendment 8** records the gate (the §9 schema-cache
|
||||||
|
refresh-vs-validation timing contract); README index updated in the same edit
|
||||||
|
(ADR-0000 rule).
|
||||||
|
|
||||||
|
## §3. Open / follow-ups
|
||||||
|
|
||||||
|
Per the user's direction the open issues come before feature work. After closing
|
||||||
|
#39, **four** issues are open. The hint/help trio (#36/#37/#38) are all
|
||||||
|
**enhancements** on the pedagogy mission (read handoff-76 §7's table for fuller
|
||||||
|
scope; read **ADR-0053** first — it orients #37/#38). **#40** is a CI/packaging
|
||||||
|
follow-up filed this session.
|
||||||
|
|
||||||
|
| # | One-line | read |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **#36** | `help <sql-form>` shows no distinct content — the 7 advanced-mode SQL nodes share `help_id: None` (`src/dsl/grammar/mod.rs:915-918`) | **Contained.** Good next pick; touches H3/`help`, maybe an ADR amendment. |
|
||||||
|
| **#37** | Clause-concept hints (cursor inside `on delete …`, `with pk`, `1:n`/`m:n`, create-table constraint slots) — deferred ADR-0053 extension | **Medium scope, richest teaching value.** Likely an ADR-0053 amendment. |
|
||||||
|
| **#38** | Pre-submit-diagnostic F1 route + ~33 `diagnostic.*` tier-3 blocks; needs a `class`/`message_key` threaded through every diagnostic site | **Broad mechanism, most marginal value.** Get the user's do/defer/close call before investing. |
|
||||||
|
| **#40** | Wire `CHANGELOG.md` into the winget release notes (`komac --release-notes-url`); ADR-0056 area | Filed this session (the changelog→winget thread, see §4). Small, deferred-by-decision originally. |
|
||||||
|
|
||||||
|
**Suggested order (confirm with the user):** #36 (contained) → #37 (highest
|
||||||
|
on-mission value) → #38 (decision first: do / defer / close). #40 is independent
|
||||||
|
(release pipeline) and can slot in whenever. #38 in particular should be
|
||||||
|
escalated for a do-or-close call rather than silently built.
|
||||||
|
|
||||||
|
## §4. Changelog discipline — new rule + open winget thread
|
||||||
|
|
||||||
|
The session surfaced that **no rule existed** for keeping `CHANGELOG.md` current
|
||||||
|
(it was created in handoff-76's plan but never given a maintenance process), so
|
||||||
|
the #39 fix wasn't logged until the user asked. Decided with the user:
|
||||||
|
|
||||||
|
- **New `CLAUDE.md` rule (this commit):** update `[Unreleased]` in the **same
|
||||||
|
change** that alters user-facing behaviour (incl. scripted/pasted/power-user
|
||||||
|
paths, not just the interactive happy path), under the two copy rules; no entry
|
||||||
|
for refactor/test/CI-only changes; at release time rename `[Unreleased]` and
|
||||||
|
**sweep commits/handoffs since the last tag** as a backstop.
|
||||||
|
- **#39 entry added** under `[Unreleased] → Fixed`.
|
||||||
|
- **winget release notes → issue #40.** komac supports `--release-notes-url` /
|
||||||
|
`--release-notes`; the `winget` job (`publish.yaml` ≈ L276) passes neither.
|
||||||
|
Tracked, not done (it's a release-pipeline / ADR-0056 change, kept out of this
|
||||||
|
bug-fix session by the user's call).
|
||||||
|
|
||||||
|
## §5. Process pins
|
||||||
|
|
||||||
|
- Commit user-confirmed, no AI attribution, append-only, on `main`; **push is
|
||||||
|
the user's step** (this commit is unpushed).
|
||||||
|
- Test-first honored: both regression tests were confirmed RED before the fix
|
||||||
|
(the PTY one via a `git stash` of `src/app.rs`), GREEN after. A written
|
||||||
|
Devil's-Advocate pass on the implementation surfaced one comment imprecision
|
||||||
|
(fixed) and no behavioral findings.
|
||||||
|
- `cargo sweep` not run this session; the build grew modestly (one extra PTY
|
||||||
|
test binary). Consider a sweep at the next milestone.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Session handoff — 2026-06-22 (78)
|
||||||
|
|
||||||
|
Continues from handoff-77 (issue #39 + changelog rule). This chunk cleared
|
||||||
|
**issue #36** — advanced-mode SQL forms now have distinct `help` content — the
|
||||||
|
first of the enhancement issues the user is working through. Three issues remain
|
||||||
|
open (#37, #38, #40).
|
||||||
|
|
||||||
|
## §1. State
|
||||||
|
|
||||||
|
**Branch `main`.** Work for #36 is staged but **not yet committed** at the time
|
||||||
|
of writing (two commits proposed: a `feat(help)` for the change + a
|
||||||
|
`docs(handoff-78)`). **Not pushed** — push is the user's step.
|
||||||
|
|
||||||
|
**Test baseline: 2525 passed / 0 failed / 1 ignored** (was 2521; +4 new
|
||||||
|
`help_command` integration tests). `clippy --all-targets -D warnings` +
|
||||||
|
`fmt --check` clean. Issue #36 to be **closed** on commit.
|
||||||
|
|
||||||
|
## §2. What shipped — issue #36
|
||||||
|
|
||||||
|
**Problem.** The in-app `help` command gave no distinct content for the six
|
||||||
|
advanced-mode SQL **DML/query** forms (`SELECT`, `WITH`, `SQL_INSERT`,
|
||||||
|
`SQL_UPDATE`, `SQL_DELETE`, `EXPLAIN_SQL`): they carried `help_id: None` (a
|
||||||
|
list-dedup shortcut), so `help select` / `help with` resolved to the
|
||||||
|
unknown-topic note and `help insert` showed only the simple form. (The advanced
|
||||||
|
SQL **DDL** forms already had `help.ddl.sql_*` pages, so the gap was
|
||||||
|
inconsistent too.)
|
||||||
|
|
||||||
|
**Decisions taken with the user** (three forks, all confirmed):
|
||||||
|
1. `help <topic>` shows **both forms** for a shared entry word, mode-blind.
|
||||||
|
2. `select` / `with` get `help_id`s and are **listed** too (consistent with the
|
||||||
|
already-listed SQL DDL forms).
|
||||||
|
3. The `help` **list** is **split by mode** into "Simple-mode commands:" and
|
||||||
|
"Advanced-mode (SQL) commands:" sections — fixing a pre-existing header bug
|
||||||
|
(next item).
|
||||||
|
|
||||||
|
**Fix (near-zero logic):**
|
||||||
|
- Gave all six advanced forms distinct `help_id`s (`data.select`, `data.with`,
|
||||||
|
`data.sql_insert`, `data.sql_update`, `data.sql_delete`, `data.explain_sql`)
|
||||||
|
with hand-curated terse `help.data.*` catalog pages (`src/dsl/grammar/data.rs`,
|
||||||
|
`src/friendly/strings/en-US.yaml`). Distinct strings ⇒ the dedup invariant
|
||||||
|
(`no_two_registered_commands_share_a_help_id`) is untouched. `note_help_topic`
|
||||||
|
needed **no** change — the forms now resolve automatically (so `help insert`
|
||||||
|
shows the simple block + the `sql_insert` block, like `help create` already
|
||||||
|
did).
|
||||||
|
- `note_help` (`src/app.rs`) now **groups by `CommandCategory`**: `app.*`
|
||||||
|
commands first (unlabelled, under the intro — they work in either mode), then
|
||||||
|
a simple-mode group and an advanced-mode (SQL) group. New catalog keys
|
||||||
|
`help.simple_section` / `help.advanced_section` replace the old
|
||||||
|
`help.dsl_section`.
|
||||||
|
- Trimmed `help.data.explain`'s advanced lines (the `explain_sql` page now owns
|
||||||
|
that). Updated `src/friendly/keys.rs` (catalog-key registry) and the stale
|
||||||
|
`help_id`-rationale comments in `src/dsl/grammar/{mod,data}.rs`.
|
||||||
|
|
||||||
|
**Copy-rule fix (bonus, in scope).** The old list header
|
||||||
|
`"DSL data commands (in simple mode):"` violated the project copy rule (the
|
||||||
|
banned word **"DSL"**) *and* mis-labelled the advanced SQL forms it already
|
||||||
|
contained as "simple mode". The split headers fix both. Verified the full `help`
|
||||||
|
output no longer contains "DSL".
|
||||||
|
|
||||||
|
**Tests** (all four red→green): `help_command::{help_select_renders_the_sql_select_block,
|
||||||
|
help_with_renders_the_cte_block, help_insert_shows_both_simple_and_sql_forms,
|
||||||
|
help_list_splits_simple_and_advanced_sections}`. Rendered output eyeballed
|
||||||
|
(alignment, both-forms, the three list groups).
|
||||||
|
|
||||||
|
**Docs.** ADR-0024 **Amendment 1** (owns `help_id`); README index updated same
|
||||||
|
edit; CHANGELOG `[Unreleased] → Added` entry (per handoff-77's new changelog
|
||||||
|
rule — landed *with* the change this time).
|
||||||
|
|
||||||
|
## §3. Copy-rule audit finding (flagged, NOT fixed) — needs a triage call
|
||||||
|
|
||||||
|
The user asked for a "DSL" sweep of help/hint strings. Result: the only
|
||||||
|
user-facing help/hint violation was the list header (fixed above). The other
|
||||||
|
catalog "DSL" hits are comments / YAML keys (internal — the rule allows those).
|
||||||
|
|
||||||
|
**One bonus finding outside help/hint, deliberately left for the user to
|
||||||
|
triage:** `src/dsl/value.rs:106` returns the error message *"literal `blob`
|
||||||
|
values are not supported in **DSL** yet"* — a likely user-facing copy-rule
|
||||||
|
violation (and it also surfaces an internal term). Not touched (out of #36
|
||||||
|
scope). **Decide: fix now / file an issue / leave.** Worth a quick check of
|
||||||
|
whether that message reaches the user raw or is wrapped by the friendly layer.
|
||||||
|
|
||||||
|
## §4. Open / follow-ups — three issues remain
|
||||||
|
|
||||||
|
| # | One-line | read |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **#37** | Clause-concept hints (cursor inside `on delete …`, `with pk`, `1:n`/`m:n`, create-table constraint slots) — deferred ADR-0053 extension | **Medium scope, richest teaching value.** Likely an ADR-0053 amendment; read ADR-0053 first. |
|
||||||
|
| **#38** | Pre-submit-diagnostic F1 route + ~33 `diagnostic.*` tier-3 blocks; needs a `class`/`message_key` threaded through every diagnostic site | **Broad mechanism, most marginal value.** Get the user's do/defer/close call before building. |
|
||||||
|
| **#40** | Wire `CHANGELOG.md` into the winget release notes (`komac --release-notes-url`); ADR-0056 area | Filed handoff-77; independent release-pipeline work. |
|
||||||
|
| (new?) | `value.rs:106` "DSL" copy-rule violation — see §3 | Not filed yet; awaiting the user's triage call. |
|
||||||
|
|
||||||
|
**Suggested next:** #37 (highest on-mission value) → #38 (escalate do/defer/close
|
||||||
|
first) → #40 (independent). Plus the §3 `value.rs` triage.
|
||||||
|
|
||||||
|
## §5. Process pins
|
||||||
|
|
||||||
|
- Test-first honoured: 4 help tests confirmed RED before the fix, GREEN after.
|
||||||
|
- Written Devil's-Advocate pass on the implementation: no blocking findings
|
||||||
|
(the dedup invariant holds with distinct ids; `note_help_topic` unchanged;
|
||||||
|
category split verified in the rendered output).
|
||||||
|
- Commits user-confirmed, no AI attribution, append-only, on `main`; push is the
|
||||||
|
user's step.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Session handoff — 2026-06-22 (79)
|
||||||
|
|
||||||
|
A long session continuing the open-issue sweep (handoff-77/78). Cleared the
|
||||||
|
**help** issue (#36) and the **schema-cache** bug (#39) earlier; this note
|
||||||
|
mainly records the big piece: **dropping the `blob` column type** (ADR-0005
|
||||||
|
Amendment 2), which grew out of a one-line copy-rule fix.
|
||||||
|
|
||||||
|
## §1. State
|
||||||
|
|
||||||
|
**Branch `main`.** Commits this session (`1a2002d` ADR-0005 Am2 + `6b4c4dc`
|
||||||
|
blob removal, plus the earlier `07575da` #39 · `e88fa79` handoff-77 · `3ad4aff`
|
||||||
|
#36 · `64818c0` handoff-78) — **the user pushed these.** A follow-up
|
||||||
|
**`style:` rustfmt commit + this CLAUDE.md/handoff doc commit** land on top
|
||||||
|
(see §5) and are the only unpushed work at handoff time.
|
||||||
|
|
||||||
|
**Suite green: 2521 passed / 0 failed / 1 ignored** (1810 lib + 8 e2e_pty + 503
|
||||||
|
it + 200 typing), `fmt --check` + `clippy -D warnings` clean — **verified via
|
||||||
|
`nix develop -c`** (the CI-exact way; see §5's lesson).
|
||||||
|
|
||||||
|
**Closed this session:** #36, #39. **Open:** #37 (clause hints), #38
|
||||||
|
(diagnostic route — needs a do/defer/close call), #40 (winget release notes).
|
||||||
|
|
||||||
|
## §2. What shipped — drop `blob` (ADR-0005 Amendment 2)
|
||||||
|
|
||||||
|
**Why** (recorded in the ADR): `blob` was a dead-end type — declarable but
|
||||||
|
never fillable (no literal in either mode, `seed`-unsupported), so a blob
|
||||||
|
column could only ever hold NULL. It rode along as the engine's fourth
|
||||||
|
storage class and never got the pedagogical scrutiny UUIDs (excluded) and
|
||||||
|
compound PKs (defended) did. The vocabulary is now **nine types**.
|
||||||
|
|
||||||
|
**Discovered via:** the `value.rs:106` binder message *"…not supported in DSL
|
||||||
|
yet"* — a user-facing copy-rule violation (it `passthrough`es to the user via
|
||||||
|
`DbError::InvalidValue`). Rather than reword, we removed the type; the message
|
||||||
|
is deleted outright.
|
||||||
|
|
||||||
|
**Implementation (test-first throughout):**
|
||||||
|
- **Removal** (compiler-guided): `Type::Blob` + its `keyword`/`sqlite`/`all()`/
|
||||||
|
`from_sql_name` alias arms; `Value::Blob`; `CellValue::Blob(Vec<u8>)` + the
|
||||||
|
base64 CSV encode/decode; the `BLOB_SLOT` grammar slot; completion's `blob`
|
||||||
|
candidate; `output_render`'s `BLOB`; the type-change `↔ blob` static refusal;
|
||||||
|
the seed blob arms; the binder refusal. User-facing strings: dropped `blob`
|
||||||
|
from `help.types_reference` and removed the `hint.value_slot_blob` slot hint.
|
||||||
|
`base64` **stays** (clipboard OSC-52).
|
||||||
|
- **Backward-compat migration** (the ADR-0015 framework's **first real use**):
|
||||||
|
a **v1→v2 format bump**. `parse_schema` hard-pinned the version to 1, so this
|
||||||
|
rippled into a `CURRENT_SCHEMA_VERSION = 2` constant used by the serializer +
|
||||||
|
parser + skeleton, the first registered migrator (`migrate_v1_to_v2`:
|
||||||
|
rewrites `type: blob`→`type: text` + bumps the version), and ~30 `version: 1`
|
||||||
|
test fixtures. The runtime (already wired to `ensure_project_yaml_migrated`)
|
||||||
|
**forces a `.db` rebuild** from the migrated text **only when a blob column
|
||||||
|
was actually converted** (`body_declares_blob_column` pre-read at both open
|
||||||
|
sites) — the stale `.db` keeps a `STRICT … BLOB` engine column + `"blob"`
|
||||||
|
metadata it would otherwise use as-is. Conversion target **text** (not drop)
|
||||||
|
was the user's call (non-destructive, CSV-free, PK/FK-safe).
|
||||||
|
- **Tests:** migrator unit tests (incl. a column *named* `blob` + a `blob`
|
||||||
|
substring in a CHECK left untouched); a **full-stack** integration test
|
||||||
|
`tests/it/blob_removal_migration.rs` (v1 blob project → migrate → `text`
|
||||||
|
column + row data preserved + `.bak`); a **Tier-4 PTY** end-to-end test
|
||||||
|
`e2e_pty::opens_a_legacy_v1_blob_project_by_migrating_to_text` (the real
|
||||||
|
binary opens a seeded legacy v1-blob project via `--resume` → migrate +
|
||||||
|
rebuild + `text` column) — added in the implementation `/runda` pass to
|
||||||
|
cover the runtime's migrate-on-open glue; updated all-types tests to nine;
|
||||||
|
regenerated 5 typing-surface snapshots (reviewed — only the `blob`/`data`
|
||||||
|
candidate vanished).
|
||||||
|
- **Docs:** ADR-0005 Amendment 2 (committed `1a2002d`) + README index; swept
|
||||||
|
`ten-type`→`nine-type` / removed `blob` from CLAUDE.md, `requirements.md`,
|
||||||
|
the website (`reference/types.md`, the seed doc's stale `not null blob`
|
||||||
|
bullet, the `rdbms.mjs` highlight grammar), and the cross-reference counts in
|
||||||
|
ADR-0030/0033/0035. CHANGELOG `[Unreleased] → Removed`.
|
||||||
|
|
||||||
|
## §3. Decisions & notes for next time
|
||||||
|
|
||||||
|
- **convert-to-text, not drop-the-column** (user, 2026-06-22): drop would force
|
||||||
|
rewriting every `data/*.csv` past the rebuild's strict header check — not
|
||||||
|
worth it for a few-days-old, few-users tool.
|
||||||
|
- **Full v2 framework migration, not parse-time leniency** (user): the proper
|
||||||
|
recorded migration with `.bak`, exercising the dormant framework.
|
||||||
|
- **`BlobLit` grammar terminal deliberately left** — a pre-existing,
|
||||||
|
`#[allow(dead_code)]` speculative token never used by any grammar; orthogonal
|
||||||
|
to the type vocabulary. Its "blob literal" string is unreachable. Removing it
|
||||||
|
is a separate optional cleanup (touches the grammar core).
|
||||||
|
- **Known assumption:** the migrator + `body_declares_blob_column` match the
|
||||||
|
serializer's *inline* column format (`- { name: …, type: … }`). A
|
||||||
|
hand-written *block-style* `project.yaml` with `type: blob` wouldn't convert
|
||||||
|
(and would then fail to parse). `project.yaml` is always machine-serialized
|
||||||
|
inline, so this is fine in practice — noted in case a future format change
|
||||||
|
moves away from inline columns.
|
||||||
|
- **Other ADRs (0030/0035) keep their historical `blob` content** (type-change
|
||||||
|
matrix, `binary`/`varbinary` aliases) per the project's supersede-don't-rewrite
|
||||||
|
ADR philosophy; ADR-0005 Amendment 2 is the authoritative record.
|
||||||
|
|
||||||
|
## §4. Process pins / next
|
||||||
|
|
||||||
|
- Commits user-confirmed, no AI attribution, append-only, on `main`; **push is
|
||||||
|
the user's step.**
|
||||||
|
- **`/runda` was run on both the ADR (design) and the implementation.** The
|
||||||
|
implementation pass found one real gap — the runtime's migrate-on-open glue
|
||||||
|
was untested — closed with the new Tier-4 PTY test. It also verified the
|
||||||
|
import path migrates (extract→migrate→rebuild) and the non-migration
|
||||||
|
`parse_schema` callers fail-safe.
|
||||||
|
- Consider a `cargo sweep` at this milestone.
|
||||||
|
- **Next open issues:** #37 (clause hints, on-mission), #38 (escalate
|
||||||
|
do/defer/close first), #40 (winget release notes).
|
||||||
|
|
||||||
|
## §5. CI/fmt lesson (cost a red CI run — don't repeat)
|
||||||
|
|
||||||
|
The blob commit `6b4c4dc` **failed the CI `fmt` gate** (run 92, `ci.yaml`).
|
||||||
|
Two compounding causes, both fixed here:
|
||||||
|
|
||||||
|
1. **`fmt` IS gated now** (`cargo fmt --check`, ADR-ci-002 Amendment 1 / issue
|
||||||
|
#35) — but `CLAUDE.md` still said "fmt is intentionally not gated yet." That
|
||||||
|
stale note bred complacency. **Corrected `CLAUDE.md`** (the gate is fmt +
|
||||||
|
clippy + test, run via `nix develop -c`).
|
||||||
|
2. **A broken local check masked the failure:** `cargo fmt --check 2>&1 | tail
|
||||||
|
-1 && echo "fmt clean"` tests `tail`'s exit code (always 0), **not** fmt's —
|
||||||
|
so "fmt clean" printed over real diffs. **Always read the exit code**, never
|
||||||
|
a piped `… | tail && echo clean`.
|
||||||
|
|
||||||
|
**Going forward, verify the CI way:** `nix develop -c cargo fmt --check` /
|
||||||
|
`nix develop -c cargo clippy --all-targets -- -D warnings` / `nix develop -c
|
||||||
|
cargo test` — matching the pinned 1.95.0 toolchain *and* the devShell env, and
|
||||||
|
checking exit codes. (A bare host `cargo` happened to be 1.95.0 this time, so
|
||||||
|
the divergence was the masking pipe, not the toolchain — but `nix develop -c`
|
||||||
|
guards both.) The `style:` rustfmt fix + this doc update land on top of
|
||||||
|
`6b4c4dc`; CI re-runs green on the next push.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Session handoff — 2026-06-25 (80)
|
||||||
|
|
||||||
|
A long session. It began as the next open-issue item — **#37, clause-concept
|
||||||
|
hints** — but the act of cutting a branch for it surfaced that this
|
||||||
|
now-public repo had **no written, trackable working method**. So the bulk of
|
||||||
|
the session became establishing one (**ADR-0059**) and hardening it through
|
||||||
|
real use, with two follow-up fixes the dogfooding turned up. Both arcs are
|
||||||
|
fully merged; the tree is clean.
|
||||||
|
|
||||||
|
## §1. State
|
||||||
|
|
||||||
|
**Branch `main` at `78272e2`.** Everything below is merged; **no open PRs**;
|
||||||
|
worktrees are just the primary (`main`) + the long-lived `website`. All
|
||||||
|
feature/fix worktrees were cleaned up with `wt-rm`.
|
||||||
|
|
||||||
|
**Suite green at #37's merge:** **1831 lib + 8 e2e_pty + 503 it + 200 typing
|
||||||
|
(2542 total), 0 failed, 1 ignored**; `fmt --check` + `clippy -D warnings`
|
||||||
|
clean (via `nix develop -c`). The new workflow scripts carry their own
|
||||||
|
local-`origin` harnesses: `test-adr-reserve.sh` **10/10**, `test-wt.sh`
|
||||||
|
**16/16**, all `shellcheck`-clean.
|
||||||
|
|
||||||
|
**Issues:** closed this session — **#37** (done, ADR-0058) and **#38**
|
||||||
|
(won't-do, see §4). Still open — **#40** (winget release notes, deferred to
|
||||||
|
next release; see §4). (#36/#39 were closed in earlier sessions.)
|
||||||
|
|
||||||
|
## §2. What shipped — #37 clause-concept hints (ADR-0058, merged #42)
|
||||||
|
|
||||||
|
The deferred ADR-0053 extension: a tier-3 **`hint.concept.*`** layer shown by
|
||||||
|
**F1** when the cursor sits **inside a recognized clause**, layered under the
|
||||||
|
per-form `hint.cmd.*` block. Seven topics — referential actions, `1:n`/`m:n`
|
||||||
|
cardinality, primary key, unique, check, foreign key.
|
||||||
|
|
||||||
|
- **Mechanism:** a new transparent `Node::Concept { topic, inner }` grammar
|
||||||
|
wrapper records the clause's byte span into `WalkContext::concept_spans`
|
||||||
|
(**append-only — survives the clause being fully matched**, unlike
|
||||||
|
`pending_hint_mode` which clears on match and so only knows the slot
|
||||||
|
boundary). `concept_topic_at_cursor` walks the full buffer and returns the
|
||||||
|
**innermost** containing span. No use of the dormant `WalkBound::Position`.
|
||||||
|
- **Two user forks:** detection = "anywhere inside the clause"; **all four
|
||||||
|
clause families** in v1. **Mode-keyed examples** (`example.simple` /
|
||||||
|
`example.advanced`) honour ADR-0053 D6 for topics reachable in both modes;
|
||||||
|
`emit_tier3_block` picks by live mode.
|
||||||
|
- **`/runda` caught two design gaps before any code** (clause map missed the
|
||||||
|
simple-mode constraint suffix; single-`example` model collided with D6) —
|
||||||
|
both fixed. **Comprehensiveness gate**: a recursive `Node::Concept` visitor
|
||||||
|
cross-checks grammar wrappers ↔ `CONCEPT_TOPICS` ↔ catalogue blocks.
|
||||||
|
- 21 new tests (14 resolver, 3 gates, 3 F1 integration, 1 snapshot).
|
||||||
|
|
||||||
|
## §3. The working method (ADR-0059, merged #41; fixes #43, #44)
|
||||||
|
|
||||||
|
The session's main artifact — the trackable flow for the public repo:
|
||||||
|
|
||||||
|
- **PRs onto a protected `main`** (always-green; one direct-push carve-out,
|
||||||
|
§4), one logical change per branch, conventional prefixes, the `/runda`/DA
|
||||||
|
review pasted into each PR. **Merge commits (`--no-ff`)**; never
|
||||||
|
rebase/squash (append-only).
|
||||||
|
- **One worktree per branch** (`<repo>-worktree-<segment>`) — never switch the
|
||||||
|
primary checkout. `scripts/wt-new.sh <branch>` (off `origin/main`,
|
||||||
|
`--no-track`) / `scripts/wt-rm.sh <branch>` (removes only the **named**
|
||||||
|
worktree).
|
||||||
|
- **Reserve-first ADR numbering** — `scripts/adr-reserve.sh <slug>`. The fix
|
||||||
|
for the real collision problem: a contiguous integer needs an allocator and
|
||||||
|
plain git branches have none, so **`main` is the registry and an atomic
|
||||||
|
`git push` is the lock** (compare-and-swap, retried on contention),
|
||||||
|
recorded in the append-only ledger **`docs/adr/RESERVATIONS.log`**. The
|
||||||
|
number is **stable from creation** (safe in immutable commit messages +
|
||||||
|
cross-refs). Supersedes ADR-0000's placeholder-until-merge default;
|
||||||
|
subproject namespaces (`ADR-website/ci-NNN`) unchanged.
|
||||||
|
- **Branch protection** (you applied in Gitea): require PR + the `ci / gate*`
|
||||||
|
check + up-to-date-before-merge; **owner whitelisted for direct push** (the
|
||||||
|
reservation ledger line, now also handoff/session docs — §4).
|
||||||
|
- **Fixes found by dogfooding:**
|
||||||
|
- **#43** — `wt-clean` (auto-sweep of all merged worktrees) → explicit
|
||||||
|
**`wt-rm <branch>`**. The auto-sweep would have deleted long-lived
|
||||||
|
branches (`website`, `ci`) since they're merged into `main` too. Also
|
||||||
|
**`wt-new --no-track`**: branching off `origin/main` had set the new
|
||||||
|
branch's upstream to `origin/main`, so a bare `git push` errored on a
|
||||||
|
name-mismatch and suggested the dangerous `HEAD:main`.
|
||||||
|
- **#44** — CI **dedupe**: `push: branches: [main]` (was `['**']`) so a
|
||||||
|
push to a PR'd branch no longer runs the gate twice. On **Gitea** the
|
||||||
|
`push` and `pull_request` runs were byte-identical — Gitea has **no
|
||||||
|
merge-preview ref**; its `pull_request` checks out `refs/pull/N/head`, the
|
||||||
|
same commit a branch push would (`docs.gitea.com/usage/actions/faq`).
|
||||||
|
Keeping the gate on `pull_request` is forward-compatible: if Gitea ever
|
||||||
|
adds the merge ref, the runs upgrade to testing the merged result for free.
|
||||||
|
|
||||||
|
## §4. Decisions
|
||||||
|
|
||||||
|
- **#38 closed — won't build** the pre-submit-diagnostic tier-3 route. The
|
||||||
|
ADR-0053 D6 deferral reasons still hold: `Diagnostic` carries no class key
|
||||||
|
(a class field would have to thread through every diagnostic-creation
|
||||||
|
site — broad change) for marginal value (tier-2 already surfaces
|
||||||
|
diagnostics live; many classes duplicate the runtime-error/clause-concept
|
||||||
|
tiers). Reconsider only if a diagnostic class has genuinely unique teaching
|
||||||
|
value. Rationale recorded on the issue.
|
||||||
|
- **#40 deferred** (open): wire `CHANGELOG` into komac's winget
|
||||||
|
`--release-notes-url`. Best coupled to the **next `v*` release + first real
|
||||||
|
winget *update* test** — the initial winget PR to `microsoft/winget-pkgs`
|
||||||
|
is still unconfirmed after several days, so end-to-end validation needs a
|
||||||
|
confirmed package first.
|
||||||
|
- **Docs direct-push carve-out** (this handoff is the first to use it):
|
||||||
|
handoff / session docs (and the reservation ledger) are **owner
|
||||||
|
direct-pushed to `main`**, not PR'd. See §5 for *why* a PR doesn't work.
|
||||||
|
|
||||||
|
## §5. Process lessons (now in ADR-0059 / CLAUDE.md)
|
||||||
|
|
||||||
|
- **Update a branch in ONE lane** — locally (`git fetch && git merge
|
||||||
|
origin/main`), **not** the Gitea "Update branch" button when a conflict is
|
||||||
|
possible. The button silently mis-resolved the README clash on #42
|
||||||
|
(dropped the `0059` index row) and, combined with a local merge, diverged
|
||||||
|
the PR branch; healed with an append-only merge (no force-push).
|
||||||
|
- **Primary checkout rests on `main`; feature work in worktrees.** Mid-session
|
||||||
|
the primary was stuck on `feat` (bootstrap artifact), which is what made
|
||||||
|
worktree cleanup awkward — there was no `main` checkout holding the
|
||||||
|
scripts. Resolved by switching the primary to `main` and moving `feat` to a
|
||||||
|
worktree.
|
||||||
|
- **Docs-only PRs are blocked by the required gate.** `paths-ignore`
|
||||||
|
(`docs/**`, `**/*.md`) means a docs-only change posts **no `ci / gate`
|
||||||
|
run**, and branch protection requires `ci / gate*` — so the required check
|
||||||
|
never arrives and the PR is stuck "expected/waiting." That is the concrete
|
||||||
|
reason handoff/session docs are direct-pushed (§4). If docs-via-PR is ever
|
||||||
|
wanted, add a "skipped → success" gate shim (or drop the paths-ignore).
|
||||||
|
- **Gitea CI specifics worth remembering:** no merge-preview ref (above); the
|
||||||
|
reserve script's CAS via atomic push; `--no-track` on branch creation to
|
||||||
|
avoid the inherited-`origin/main` upstream.
|
||||||
|
|
||||||
|
## §6. Next
|
||||||
|
|
||||||
|
- **Next session is free to pick a feature** — no specific item is queued;
|
||||||
|
consult `docs/requirements.md` for the remaining backlog (e.g. **TT4 /
|
||||||
|
Tier-4-in-CI** still not wired; **D3 packaging** — winget pending
|
||||||
|
confirmation; the larger deferred UX items: tutorial system, session-log +
|
||||||
|
markdown export V4, multi-line input I1, ER-diagram export V3).
|
||||||
|
- **#40** when the next release happens (with the first winget update test).
|
||||||
|
- **Use the new flow:** `wt-new` to start a branch, `adr-reserve` the moment
|
||||||
|
an ADR is needed, PR onto `main`, `wt-rm` when done. Push/merge stay your
|
||||||
|
steps; agents prepare but never push.
|
||||||
|
|
||||||
|
## §7. Process pins
|
||||||
|
|
||||||
|
- Commits user-confirmed, no AI attribution, append-only. **Push/merge are
|
||||||
|
the user's steps.** This handoff + the §4 carve-out doc amendments are
|
||||||
|
direct-pushed to `main` by the owner (docs-only, paths-ignored,
|
||||||
|
owner-whitelisted).
|
||||||
@@ -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.)
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Plan — TT4 (Tier-4 PTY tests) + NFR verification + CHANGELOG
|
||||||
|
|
||||||
|
Status: **approved 2026-06-22**, implementation pending. Supersedes the
|
||||||
|
transient harness plan-mode copy. This is the persistent, version-controlled
|
||||||
|
plan per project convention (`docs/plans/`).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
RDBMS Playground now ships public binaries (v0.2.0 on crates.io, plus
|
||||||
|
Scoop/Homebrew/winget). With real users pulling updates, the priority is
|
||||||
|
**regression protection before the next release**. Three tracked gaps are taken
|
||||||
|
up together:
|
||||||
|
|
||||||
|
- **TT4** (`requirements.md`, ADR-0008 Tier 4): the four critical end-to-end
|
||||||
|
flows are specified but **no PTY harness exists** — no deps, no tests. Tiers
|
||||||
|
1–3 never exercise the *real built binary* through a terminal.
|
||||||
|
- **NFR-1..7**: quality bars (startup, latency, memory, contrast, distinctive
|
||||||
|
design, parity) that were **never formally verified** despite shipping.
|
||||||
|
- **CHANGELOG**: none exists; useful now that releases are public (and lets the
|
||||||
|
CI `winget` job pass `--release-notes-url` later).
|
||||||
|
|
||||||
|
Baseline (Phase 1): `cargo test` = **2509 passed / 0 failed / 1 ignored**;
|
||||||
|
clippy + fmt clean. This is the number Phase 5 verifies against (new tests add
|
||||||
|
to it; nothing may regress).
|
||||||
|
|
||||||
|
User decisions (Phase 3):
|
||||||
|
- **TT4 in CI:** own `e2e_pty` test target, runs by default in `cargo test`
|
||||||
|
(so the Linux gate exercises it every push). **Tight, fail-fast timeouts** —
|
||||||
|
the runner is self-hosted, low-parallelism, low expected flakiness (so a
|
||||||
|
failure surfaces fast rather than hanging on a generous timeout).
|
||||||
|
- **NFR:** WCAG contrast = hard gated test; startup + idle-memory = measured via
|
||||||
|
the PTY harness against *generous* bounds + numbers recorded; NFR-2/4/6 =
|
||||||
|
verify-by-argument + reviewer note. Each requirement → `[x]`/`[/]` with
|
||||||
|
evidence.
|
||||||
|
- **CHANGELOG:** Keep a Changelog + SemVer; `[Unreleased]` + a single
|
||||||
|
consolidated `[0.2.0]` only (0.1.0 treated as pre-history).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workstream 1 — TT4: Tier-4 PTY harness + 4 flows
|
||||||
|
|
||||||
|
### Tooling (refines ADR-0008's stated `portable-pty`+`expectrl`+`vt100`)
|
||||||
|
- **`portable-pty`** (wezterm, actively maintained) — spawn the real binary in a
|
||||||
|
PTY with a fixed window size.
|
||||||
|
- **`vt100`** — parse the PTY output stream into an inspectable cell grid.
|
||||||
|
- **Drop `expectrl`** — it bundles its *own* PTY abstraction (conflicts with
|
||||||
|
portable-pty) and is line-oriented, a poor fit for a full-screen TUI. Replace
|
||||||
|
it with a small hand-rolled `wait_for(predicate, timeout)` polling the vt100
|
||||||
|
screen. This deviation is recorded in the ADR-0008 amendment.
|
||||||
|
- **Checkpoint at implementation:** verify `vt100` is still maintained/current
|
||||||
|
(global currency rule). If stale, evaluate `avt` as the screen parser before
|
||||||
|
committing the dep. `cargo add` picks latest; pin sensibly.
|
||||||
|
|
||||||
|
### Harness (`tests/e2e_pty.rs` — new `[[test]]` target)
|
||||||
|
A `support` module providing:
|
||||||
|
- `struct PtyApp` — opens a PTY (e.g. **100×30**, the wide three-region
|
||||||
|
layout), spawns `env!("CARGO_BIN_EXE_rdbms-playground")` with a **per-test
|
||||||
|
temp `--data-dir`** (`tempfile::TempDir`), `--theme dark`, `TERM=xterm-256color`;
|
||||||
|
a reader thread feeds bytes into a `vt100::Parser`.
|
||||||
|
- `send(bytes)` (raw; `\r` = Enter, `\x03` = Ctrl-C), `send_line(s)` =
|
||||||
|
`s` + `\r`.
|
||||||
|
- `wait_for(&str, Duration)` — poll the vt100 screen text until the substring
|
||||||
|
appears or the (tight, ~**3 s**) timeout fires; on timeout **panic with a full
|
||||||
|
screen dump** (fail-fast + debuggable). A `screen_text()` helper for assertions.
|
||||||
|
- `quit()` — `send("\x03")`, wait for child exit, assert success.
|
||||||
|
- Run **serially** (`serial_test` dep, or a shared `Mutex`/single-threaded
|
||||||
|
target) — PTYs + temp dirs shouldn't race; also keeps timing stable.
|
||||||
|
- **Instrument** with `eprintln!`/screen dumps on every wait so a CI failure is
|
||||||
|
diagnosable from logs (per the project's logging discipline).
|
||||||
|
|
||||||
|
### The four flows (one `#[test]` each, mirroring ADR-0008 §Tier-4)
|
||||||
|
1. **Cold launch → DDL → quit.** Launch fresh; `wait_for("SIMPLE")` +
|
||||||
|
`wait_for("(none yet)")`; `send_line("create table Customers with pk
|
||||||
|
id(serial)")`; `wait_for("Customers")` in the Tables panel; `quit()`.
|
||||||
|
2. **Save → restart → reopen.** Launch project path P under the temp data-dir;
|
||||||
|
create a table; `quit()` (autosave already persisted per-command); relaunch
|
||||||
|
**same P**; `wait_for("Customers")` — schema restored from text/rebuild.
|
||||||
|
3. **Export → import → rebuild.** Project A: create table + insert a row;
|
||||||
|
`send_line("export A.zip")` (explicit path under the temp dir);
|
||||||
|
`wait_for` export-success note; `quit()`. Fresh project B:
|
||||||
|
`send_line("import <path>/A.zip")`; `wait_for` import note (rebuild auto-runs
|
||||||
|
on missing `.db`); assert the table + row are present (`show data` /
|
||||||
|
Tables panel).
|
||||||
|
4. **Undo after DROP.** Create table; `send_line("drop table Customers")`;
|
||||||
|
confirm gone; `send_line("undo")`; **`wait_for("Restore that earlier
|
||||||
|
state?")`** (the real modal); `send("y")`; `wait_for("Customers")` restored.
|
||||||
|
|
||||||
|
### CI
|
||||||
|
No workflow edit needed for execution: `e2e_pty` is a default target, so the
|
||||||
|
existing `gate` job's `cargo test --no-fail-fast` runs it on the Linux
|
||||||
|
container. **Validate locally first** that a PTY opens inside the
|
||||||
|
`rdbms-playground-ci` image (container `/dev/ptmx`); if not, add `--init`/pts
|
||||||
|
mount notes to the ADR. Advances **TT5** (Tier-4-in-CI on Linux); Windows
|
||||||
|
execution runner stays out of scope (no runner).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workstream 2 — NFR verification
|
||||||
|
|
||||||
|
### Gated: WCAG contrast (NFR-5, NFR-7) — `src/theme.rs` `#[cfg(test)]`
|
||||||
|
- Add a `relative_luminance(Color) -> f64` + `contrast_ratio(a,b) -> f64`
|
||||||
|
helper (WCAG 2.x sRGB formula) in the test module; a `match Color::Rgb` channel
|
||||||
|
extractor (panics on non-RGB — also a guard against a future named-colour
|
||||||
|
regression).
|
||||||
|
- For **both** `Theme::light()` and `Theme::dark()`, assert **≥ 4.5:1** for
|
||||||
|
normal-text foregrounds on `bg`: `fg`, `error`, `warning`, `system`,
|
||||||
|
`plan_efficient`, `mode_simple`, `mode_advanced`, and the info-carrying syntax
|
||||||
|
tokens (`tok_keyword/identifier/type/number/string/flag/function/error`).
|
||||||
|
- **`muted`/`tok_punct`** (deliberately dim secondary text) and **borders**
|
||||||
|
(`border`/`border_advanced`, non-text UI): assert the WCAG **non-text 3:1**
|
||||||
|
threshold, with a comment citing the WCAG large-text/non-text allowance. If
|
||||||
|
any *fails even 3:1*, that's a real finding → surface to the user, don't
|
||||||
|
silently relax. (Supersedes the existing inequality-only theme tests.)
|
||||||
|
- This runs in the existing gate — permanent regression protection on the palette.
|
||||||
|
|
||||||
|
### Measured + documented: startup (NFR-1) + idle memory (NFR-3)
|
||||||
|
Two measurement tests in `e2e_pty.rs`:
|
||||||
|
- **Startup:** time from spawn to `wait_for("SIMPLE")` (first rendered frame);
|
||||||
|
assert a **generous** bound (e.g. < 1500 ms locally) and `eprintln!` the actual.
|
||||||
|
- **Idle memory:** after the app is idle, read the child's `/proc/<pid>/status`
|
||||||
|
`VmRSS` (Linux); assert a generous bound and print actual. (Linux-gated via
|
||||||
|
`#[cfg(target_os = "linux")]`.)
|
||||||
|
The point is gross-regression detection, not a tight SLA gate (user decision).
|
||||||
|
|
||||||
|
### Verify-by-argument + reviewer note (NFR-2, NFR-4, NFR-6)
|
||||||
|
- **NFR-2** (input off-thread): already architecturally true — DB runs on the
|
||||||
|
worker thread (ADR-0010) and input on a separate Tokio task (confirmed in
|
||||||
|
`runtime.rs`). Record the evidence; optionally a test that a query doesn't
|
||||||
|
block a subsequent keystroke render.
|
||||||
|
- **NFR-4** (distinctive design) / **NFR-6** (cross-platform parity): qualitative
|
||||||
|
/ partly-CI. Written reviewer verification (DA-hat) in the NFR verification doc;
|
||||||
|
parity partly evidenced by the CI matrix (Linux+macOS execute, Windows builds).
|
||||||
|
|
||||||
|
### Requirements bookkeeping
|
||||||
|
Move NFR-5/7 → `[x]` (gated test), NFR-1/3 → `[x]` (measured + bounded test +
|
||||||
|
recorded numbers), NFR-2 → `[x]` (architectural evidence), NFR-4/6 → `[/]` with
|
||||||
|
the reviewer note (honest: not fully automatable). Each with a commit/test ref.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workstream 3 — CHANGELOG
|
||||||
|
|
||||||
|
- New **`CHANGELOG.md`** at repo root, **Keep a Changelog** + SemVer.
|
||||||
|
- `[Unreleased]` (empty/seeded) + one consolidated **`[0.2.0] - <release date>`**
|
||||||
|
with Added/Changed/Fixed of the notable user-facing changes (install methods,
|
||||||
|
packaging, `--version`/`version`, seed improvements, hint feature, readline
|
||||||
|
keys, cross-mode history) mined from ADRs 0053–0056 + handoffs 73–75. 0.1.0 =
|
||||||
|
pre-history (a one-line note).
|
||||||
|
- Optional follow-up (flag, don't auto-do): wire `--release-notes-url` into the
|
||||||
|
CI `winget`/release path — out of scope unless you want it now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docs / ADR updates (project discipline)
|
||||||
|
|
||||||
|
- **Amend ADR-0008** (Tier-4 section): record the realized tooling
|
||||||
|
(`portable-pty` + `vt100` + hand-rolled `wait_for`, `expectrl` dropped),
|
||||||
|
serial execution, tight-timeout/fail-fast stance, the default-target CI wiring,
|
||||||
|
and the four implemented flows. Update `docs/adr/README.md` in the same edit.
|
||||||
|
- **New ADR-0057 — NFR verification strategy** (next free number; assigned at
|
||||||
|
merge per ADR-0000): the contrast-gate + thresholds (incl. the 3:1
|
||||||
|
muted/border allowance), the measured-not-tightly-gated stance for
|
||||||
|
startup/memory, and the verify-by-argument treatment of NFR-2/4/6. Add a
|
||||||
|
short **NFR verification record** (the measured numbers + reviewer notes) —
|
||||||
|
either in the ADR or a `docs/` companion. Update the README index.
|
||||||
|
- Update `docs/requirements.md`: TT4 `[~]`→`[x]`, TT5 `[/]` note (Tier-4 now in
|
||||||
|
Linux CI; Windows-exec still pending), NFR rows as above.
|
||||||
|
- A handoff note (next sequence number) per project convention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification (Phase 5)
|
||||||
|
|
||||||
|
1. `cargo test` — full suite green; new e2e_pty + contrast tests pass; **2509 +
|
||||||
|
new, 0 failed, ≤1 ignored**; no regressions vs baseline.
|
||||||
|
2. `cargo clippy --all-targets -- -D warnings` + `cargo fmt --check` clean.
|
||||||
|
3. Run `e2e_pty` repeatedly (e.g. 5–10×) locally to confirm non-flaky under the
|
||||||
|
tight timeouts before relying on the gate.
|
||||||
|
4. Confirm a PTY opens inside the CI image (or document the fix).
|
||||||
|
5. DA-hat pass over each workstream + the NFR reviewer judgements, written down.
|
||||||
|
6. `cargo sweep` at the milestone (CLAUDE.md build hygiene).
|
||||||
|
|
||||||
|
## Risks / checkpoints
|
||||||
|
- **vt100 currency** — verify before adding; fall back to `avt` if stale.
|
||||||
|
- **PTY-in-container** — validate early; the whole TT4 CI value depends on it.
|
||||||
|
- **muted/border contrast** — may fail even 3:1; if so it's a finding to escalate,
|
||||||
|
not silently relax (could mean a small palette tweak — touches a decided area,
|
||||||
|
so escalate).
|
||||||
|
- **Commits** — confirm each message; no AI attribution; append-only; never push.
|
||||||
+121
-41
@@ -70,8 +70,11 @@ since ADR-0027.)
|
|||||||
natively on a Tart Apple-Silicon runner via the dispatched
|
natively on a Tart Apple-Silicon runner via the dispatched
|
||||||
`release-macos.yaml`. All uploaded to the Gitea release with a
|
`release-macos.yaml`. All uploaded to the Gitea release with a
|
||||||
`.sha256` each. Decisions in `docs/ci/adr/` (ADR-ci-001/002/003).
|
`.sha256` each. Decisions in `docs/ci/adr/` (ADR-ci-001/002/003).
|
||||||
Runtime-verified by the user: Linux x86_64 + Windows aarch64; the
|
Runtime-verified by the user: Linux x86_64, Windows aarch64, and both
|
||||||
others are link-clean / valid format.)*
|
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.
|
- [x] **D2** Single static binary, no runtime dependencies.
|
||||||
*(Done 2026-06-15, per platform: **Linux** is fully static (musl +
|
*(Done 2026-06-15, per platform: **Linux** is fully static (musl +
|
||||||
`crt-static`); **Windows** is a standalone `.exe` (Zig statically
|
`crt-static`); **Windows** is a standalone `.exe` (Zig statically
|
||||||
@@ -82,11 +85,24 @@ since ADR-0027.)
|
|||||||
No target requires anything the user must install. ADR-ci-003.)*
|
No target requires anything the user must install. ADR-ci-003.)*
|
||||||
- [ ] **D3** Released via prebuilt binaries plus Homebrew, Scoop,
|
- [ ] **D3** Released via prebuilt binaries plus Homebrew, Scoop,
|
||||||
`winget`, and `cargo binstall`.
|
`winget`, and `cargo binstall`.
|
||||||
*(Prebuilt binaries + checksums now published to Gitea releases
|
*(Prebuilt binaries + checksums on Gitea releases (D1); **`cargo
|
||||||
(D1); the package-manager manifests (Homebrew / Scoop / winget /
|
binstall` + crates.io live** (ADR-0056); **Scoop + Homebrew wired and
|
||||||
`cargo binstall`) remain to do. The asset naming
|
validated end-to-end** on real Windows + macOS (ADR-0056 Amendments
|
||||||
`rdbms-playground-<tag>-<target>` is already binstall-friendly.
|
2 & 3) — `publish.yaml` `scoop-bucket` / `homebrew-tap` jobs render
|
||||||
Tracked under ADR-ci-003 "Deferred".)*
|
dependency-free manifests from the release `.sha256` sidecars and push
|
||||||
|
them, via the scoped `lazyeval-ci` bot token, to
|
||||||
|
`lazyeval/scoop-bucket` and `lazyeval/homebrew-tap`; rendering covered
|
||||||
|
by `scripts/test-package-renders.sh`. NB: the Homebrew path depends on
|
||||||
|
a **server-side Caddy rule** answering HEAD on release-download paths
|
||||||
|
directly (Gitea's presigned-S3 redirect is method-bound and brew
|
||||||
|
reuses a HEAD-signed URL for its GET) — see ADR-0056 Amendment 3;
|
||||||
|
that rule lives in the Gitea edge config, not this repo. **winget
|
||||||
|
wired** (ADR-0056 Amendment 4) — a `publish.yaml` `winget` job submits
|
||||||
|
a PR to `microsoft/winget-pkgs` via komac (`LazyEvaluation.RdbmsPlayground`,
|
||||||
|
portable, x64+arm64), guarded against duplicate PRs; pending the
|
||||||
|
**one-time manual `komac new` bootstrap** + Microsoft review before
|
||||||
|
it goes live. Asset naming `rdbms-playground-<tag>-<target>` is
|
||||||
|
binstall-friendly.)*
|
||||||
|
|
||||||
## TUI shell
|
## TUI shell
|
||||||
|
|
||||||
@@ -388,7 +404,7 @@ since ADR-0027.)
|
|||||||
referenced column), then `ALTER TABLE … ALTER COLUMN TYPE` (4f —
|
referenced column), then `ALTER TABLE … ALTER COLUMN TYPE` (4f —
|
||||||
runtime-decomposed to `change_column_type` with `ForceConversion`, the
|
runtime-decomposed to `change_column_type` with `ForceConversion`, the
|
||||||
§7 advanced policy: lossy converts with a note, incompatible + static
|
§7 advanced policy: lossy converts with a note, incompatible + static
|
||||||
refusals (`↔ blob`, non-`int → serial`) refuse, `int → serial` allowed;
|
refusals (non-`int → serial`) refuse, `int → serial` allowed;
|
||||||
the internal-`__rdbms_*` guard folded into `do_change_column_type`),
|
the internal-`__rdbms_*` guard folded into `do_change_column_type`),
|
||||||
then `ALTER TABLE` add/drop constraint + add FK (4g — `ADD [CONSTRAINT
|
then `ALTER TABLE` add/drop constraint + add FK (4g — `ADD [CONSTRAINT
|
||||||
<name>] (CHECK | UNIQUE | FOREIGN KEY)` + `DROP CONSTRAINT <name>`;
|
<name>] (CHECK | UNIQUE | FOREIGN KEY)` + `DROP CONSTRAINT <name>`;
|
||||||
@@ -414,7 +430,7 @@ since ADR-0027.)
|
|||||||
refused with an engine-neutral message naming the construct.
|
refused with an engine-neutral message naming the construct.
|
||||||
Implementation pending.)*
|
Implementation pending.)*
|
||||||
- [x] **Q3** User-facing simplified types map transparently to
|
- [x] **Q3** User-facing simplified types map transparently to
|
||||||
SQLite STRICT types in generated DDL. *(All ten types implemented
|
SQLite STRICT types in generated DDL. *(All nine types implemented
|
||||||
and tested.)*
|
and tested.)*
|
||||||
- [x] **Q4** Supported SQL subset specification — **ADR-0030**.
|
- [x] **Q4** Supported SQL subset specification — **ADR-0030**.
|
||||||
Advanced mode is a standard-SQL surface, engine-neutral; the
|
Advanced mode is a standard-SQL surface, engine-neutral; the
|
||||||
@@ -446,10 +462,11 @@ since ADR-0027.)
|
|||||||
|
|
||||||
## Type system (per ADR-0005)
|
## Type system (per ADR-0005)
|
||||||
|
|
||||||
- [x] **T1** All ten user-facing types implemented: `text`,
|
- [x] **T1** All nine user-facing types implemented: `text`,
|
||||||
`int`, `real`, `decimal`, `bool`, `date`, `datetime`, `blob`,
|
`int`, `real`, `decimal`, `bool`, `date`, `datetime`,
|
||||||
`serial`, `shortid`. *(Mapping to SQLite STRICT covered by
|
`serial`, `shortid`. *(Mapping to SQLite STRICT covered by
|
||||||
ADR-0005; FK target type rule by ADR-0011.)*
|
ADR-0005; FK target type rule by ADR-0011; `blob` dropped by
|
||||||
|
ADR-0005 Amendment 2.)*
|
||||||
- [x] **T2** `shortid` generation: base58, 10–12 characters,
|
- [x] **T2** `shortid` generation: base58, 10–12 characters,
|
||||||
omits ambiguous characters; generated client-side at insert.
|
omits ambiguous characters; generated client-side at insert.
|
||||||
*(Implemented per ADR-0014; auto-fills omitted shortid
|
*(Implemented per ADR-0014; auto-fills omitted shortid
|
||||||
@@ -820,7 +837,12 @@ since ADR-0027.)
|
|||||||
(`what`/`example`/`concept`) covers every command form + the 9 runtime
|
(`what`/`example`/`concept`) covers every command form + the 9 runtime
|
||||||
error classes, enforced by a comprehensiveness coverage test. Deferred:
|
error classes, enforced by a comprehensiveness coverage test. Deferred:
|
||||||
the pre-submit-diagnostic route + `diagnostic.*` blocks (#38),
|
the pre-submit-diagnostic route + `diagnostic.*` blocks (#38),
|
||||||
clause-concept hints (#37).)*
|
clause-concept hints (#37). **Content verified 2026-06-15 (handoff-71):**
|
||||||
|
a semantic pass over every `hint.cmd.*`/`hint.err.*` block fixed four
|
||||||
|
errors — `create_table` (compound-PK misread), `save` (no inline name),
|
||||||
|
`import` (hyphen-rejecting target), and `foreign_key.child_side` (wrong
|
||||||
|
`on delete` remedy) — and added a catalogue-driven guard test that parses
|
||||||
|
every command example in its taught mode.)*
|
||||||
- [x] **H3** `help` provides general reference and per-command
|
- [x] **H3** `help` provides general reference and per-command
|
||||||
help.
|
help.
|
||||||
*(Done 2026-06-07: the **general reference** is `help` (no arg) —
|
*(Done 2026-06-07: the **general reference** is `help` (no arg) —
|
||||||
@@ -883,7 +905,10 @@ since ADR-0027.)
|
|||||||
exists (~55 lines, covers the WHERE-expression and
|
exists (~55 lines, covers the WHERE-expression and
|
||||||
table-creation boundaries). **Missing:** a DSL
|
table-creation boundaries). **Missing:** a DSL
|
||||||
command-surface reference and a standalone type-system
|
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)
|
## Testing (per ADR-0008)
|
||||||
|
|
||||||
@@ -894,28 +919,44 @@ since ADR-0027.)
|
|||||||
for representative views.
|
for representative views.
|
||||||
- [x] **TT3** Tier 3: synthetic event-loop integration tests
|
- [x] **TT3** Tier 3: synthetic event-loop integration tests
|
||||||
covering the user-facing flows in this checklist.
|
covering the user-facing flows in this checklist.
|
||||||
- [~] **TT4** Tier 4: PTY-based end-to-end for the four critical
|
- [x] **TT4** Tier 4: PTY-based end-to-end for the four critical
|
||||||
flows named in ADR-0008 (cold launch → DDL → quit; save →
|
flows named in ADR-0008 (cold launch → DDL → quit; save →
|
||||||
reopen; export → import → rebuild; undo after DROP).
|
reopen; export → import → rebuild; undo after DROP).
|
||||||
*(Verified 2026-06-07: **nothing is wired** — no
|
*(Implemented 2026-06-22 — ADR-0008 **Amendment 1**;
|
||||||
`portable-pty` / `expectrl` / `vt100` dependencies, no PTY test
|
`tests/e2e_pty.rs` (commit `fd63de3`). Drives the actual built
|
||||||
files; ADR-0008 §Tier-4 is a specification only. The Tier-3
|
binary in a real pseudo-terminal: `portable-pty` + `vt100`
|
||||||
`tests/it/*_e2e.rs` files are synthetic event-loop tests, not
|
(**`expectrl` dropped** — conflicting PTY layer, replaced by a
|
||||||
PTY. Correcting a stale `CLAUDE.md` line that read "Tier 4 is
|
hand-rolled `wait_for` on the vt100 grid). Fixed 100×30, per-test
|
||||||
wired only for the listed critical flows" — it was not wired at
|
temp `--data-dir`, serial, tight fail-fast 3 s waits; table
|
||||||
all. Genuinely deferred.)*
|
presence read from the Tables sidebar region (Output echoes
|
||||||
|
commands); commands paced to completion (stale-schema-cache
|
||||||
|
misparse on faster-than-human input → issue #39). All four flows
|
||||||
|
green (flow 2 asserts a **column** round-trips, not just the
|
||||||
|
table name). Was previously a spec-only deferral; the earlier
|
||||||
|
2026-06-07 "nothing is wired" note is now resolved.)*
|
||||||
- [/] **TT5** CI runs all tiers on Linux, macOS, and Windows on
|
- [/] **TT5** CI runs all tiers on Linux, macOS, and Windows on
|
||||||
stable Rust.
|
stable Rust.
|
||||||
*(Partial, 2026-06-15. **CI is live** on the self-hosted Gitea
|
*(Partial, updated 2026-06-22. **CI is live** on the self-hosted
|
||||||
Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings` +
|
Gitea Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings`
|
||||||
`cargo test` (Tiers 1–3) on the **Linux** runner for every branch
|
+ `cargo test` (now Tiers **1–4** — the `e2e_pty` target runs by
|
||||||
push / PR, and `release-macos` runs the suite natively on the
|
default) on the **Linux** runner for every branch push / PR.
|
||||||
**macOS** runner. **Windows is build-only** — cross-compiled, not
|
**macOS is fully covered**: `release-macos` runs the whole suite
|
||||||
executed (no Windows runner). **Tier 4** (PTY, TT4) is still
|
natively on the macOS runner, and Tier 4 was confirmed green on
|
||||||
unwired, so "all tiers" is not yet fully met. "Stable Rust" is
|
macOS (2026-06-22 — the 4 flows + startup; the Linux-only RSS probe
|
||||||
satisfied by the flake's pinned `1.95.0` (a stable release, not
|
is `#[cfg]`-skipped). **Tier 4 in CI on Linux is met** (TT4);
|
||||||
nightly). Remaining for full TT5: a Windows execution runner and
|
PTY-in-container was validated (openpty + spawn under Docker), with
|
||||||
Tier-4 PTY in CI.)*
|
the first real gate run the final confirmation. "Stable Rust" is
|
||||||
|
satisfied by the flake's pinned `1.95.0`.
|
||||||
|
**Windows — automated execution is the one open piece.** Windows
|
||||||
|
binaries are first-class: cross-built and shipped for every release
|
||||||
|
(D1/D2), and we verify them by **running the suite on Windows by
|
||||||
|
hand from time to time** so the Windows experience stays sound
|
||||||
|
between releases. A standing Windows CI runner is more involved to
|
||||||
|
operate than the Linux and macOS runners already in use; we intend
|
||||||
|
to keep looking for a clean solution but make no promises on timing.
|
||||||
|
Until then, Windows users get exactly the same builds, verified
|
||||||
|
manually rather than on every push. So TT5 stays `[/]` by deliberate
|
||||||
|
scope, not neglect.)*
|
||||||
|
|
||||||
## Cross-cutting
|
## Cross-cutting
|
||||||
|
|
||||||
@@ -991,41 +1032,80 @@ target is measurable, it is stated numerically; where it is
|
|||||||
necessarily qualitative, the criterion is named and the bar is
|
necessarily qualitative, the criterion is named and the bar is
|
||||||
"reviewer judgement against the criterion."
|
"reviewer judgement against the criterion."
|
||||||
|
|
||||||
- [ ] **NFR-1 Performance — startup.** Cold launch to first
|
All seven were formally verified 2026-06-22 (ADR-0057). Approach:
|
||||||
|
contrast/distinctness **gated** by tests, startup/memory **measured**
|
||||||
|
against the targets, the rest **verified by argument / reviewer note**.
|
||||||
|
|
||||||
|
- [x] **NFR-1 Performance — startup.** Cold launch to first
|
||||||
rendered frame under 500ms on commodity hardware (developer
|
rendered frame under 500ms on commodity hardware (developer
|
||||||
laptop, mid-range desktop). Measured in CI on the Linux runner
|
laptop, mid-range desktop).
|
||||||
as a regression gate.
|
*(Verified 2026-06-22, ADR-0057 — measured via the Tier-4 PTY
|
||||||
- [ ] **NFR-2 Performance — input latency.** Keystroke-to-render
|
harness; release-binary median **~29 ms** to first frame (5
|
||||||
|
trials, this dev machine), ~17× under target. The harness test
|
||||||
|
`startup_to_first_frame_is_reasonable` gates a generous
|
||||||
|
debug-binary bound for gross-regression detection; a tight 500 ms
|
||||||
|
CI gate was declined as flaky on a shared runner — user decision.)*
|
||||||
|
- [x] **NFR-2 Performance — input latency.** Keystroke-to-render
|
||||||
latency under 16ms during normal editing; long-running queries
|
latency under 16ms during normal editing; long-running queries
|
||||||
must execute off the UI thread so the interface remains
|
must execute off the UI thread so the interface remains
|
||||||
responsive (typing, scrolling, mode switching) while a query is
|
responsive (typing, scrolling, mode switching) while a query is
|
||||||
running.
|
running.
|
||||||
- [ ] **NFR-3 Performance — resource footprint.** Idle memory
|
*(Verified 2026-06-22, ADR-0057 — by architecture: database work
|
||||||
|
runs on the dedicated worker thread (ADR-0010) and terminal input
|
||||||
|
on a separate Tokio task (`spawn_event_reader`, `runtime.rs`), so
|
||||||
|
a running query cannot block input/render. No automated sub-16 ms
|
||||||
|
latency test — it would be flaky and low-value.)*
|
||||||
|
- [x] **NFR-3 Performance — resource footprint.** Idle memory
|
||||||
under 50MB on the smallest target platform; no busy-loops; CPU
|
under 50MB on the smallest target platform; no busy-loops; CPU
|
||||||
near zero when waiting for input.
|
near zero when waiting for input.
|
||||||
- [ ] **NFR-4 Visual quality — distinctive design.** Colour
|
*(Verified 2026-06-22, ADR-0057 — release-binary idle RSS
|
||||||
|
**~10 MB** (5 trials, Linux), ~5× under target. The event loop
|
||||||
|
blocks on `recv` (no busy-loop). The harness test
|
||||||
|
`idle_memory_footprint_is_reasonable` gates a generous
|
||||||
|
debug-binary RSS bound on Linux.)*
|
||||||
|
- [/] **NFR-4 Visual quality — distinctive design.** Colour
|
||||||
palette and typography are deliberate and consistent across
|
palette and typography are deliberate and consistent across
|
||||||
views; layout uses Unicode box-drawing and symbols where they
|
views; layout uses Unicode box-drawing and symbols where they
|
||||||
add clarity; rendering avoids the generic flat-default look
|
add clarity; rendering avoids the generic flat-default look
|
||||||
that ships with most TUI frameworks. Criterion: a reviewer can
|
that ships with most TUI frameworks. Criterion: a reviewer can
|
||||||
identify the app from a screenshot of any view.
|
identify the app from a screenshot of any view.
|
||||||
- [ ] **NFR-5 Visual quality — colour use.** Colour conveys
|
*(Reviewer note 2026-06-22, ADR-0057 — satisfied by the bespoke
|
||||||
|
two-theme palette, the three-region framed layout, and the
|
||||||
|
annotated plan/relationship rendering; qualitative, not
|
||||||
|
automatable.)*
|
||||||
|
- [x] **NFR-5 Visual quality — colour use.** Colour conveys
|
||||||
information rather than decoration: mode indication, query
|
information rather than decoration: mode indication, query
|
||||||
result types (numeric vs text vs null), error severity,
|
result types (numeric vs text vs null), error severity,
|
||||||
syntax highlighting categories. Foreground/background
|
syntax highlighting categories. Foreground/background
|
||||||
combinations meet WCAG-AA contrast (4.5:1 for normal text)
|
combinations meet WCAG-AA contrast (4.5:1 for normal text)
|
||||||
even though we have not committed to broader accessibility.
|
even though we have not committed to broader accessibility.
|
||||||
- [ ] **NFR-6 Cross-platform parity.** Behaviour and visual
|
*(Verified 2026-06-22, ADR-0057 — **gated** in `src/theme.rs`:
|
||||||
|
every text foreground clears 4.5:1 on both themes; the
|
||||||
|
advanced-mode border clears 3:1 (plain border decorative-exempt);
|
||||||
|
token pairs clear ΔE2000 ≥ 15. Writing the gate caught + fixed two
|
||||||
|
shipped light-theme defects (`tok_string` 4.42:1, `tok_flag`
|
||||||
|
3.15:1) and two near-duplicate dark pairs, `65eab71`.)*
|
||||||
|
- [/] **NFR-6 Cross-platform parity.** Behaviour and visual
|
||||||
quality are equivalent across Linux, macOS, and Windows on
|
quality are equivalent across Linux, macOS, and Windows on
|
||||||
crossterm-supported terminals. Platform-specific divergence
|
crossterm-supported terminals. Platform-specific divergence
|
||||||
(e.g. font fallbacks) is documented, not silently tolerated.
|
(e.g. font fallbacks) is documented, not silently tolerated.
|
||||||
- [ ] **NFR-7 Light and dark background support.** The colour
|
*(Reviewer note 2026-06-22, ADR-0057 — partly evidenced by the CI
|
||||||
|
matrix (Linux + macOS execute the suite incl. Tier 4; Windows is
|
||||||
|
build-only). Documented divergence: on terminals/multiplexers
|
||||||
|
without true-colour passthrough the 24-bit palette is quantised to
|
||||||
|
256 colours and near hues can collide (a terminal-capability
|
||||||
|
issue). Full parity awaits a Windows execution runner — see TT5.)*
|
||||||
|
- [x] **NFR-7 Light and dark background support.** The colour
|
||||||
scheme remains legible and visually coherent on both light and
|
scheme remains legible and visually coherent on both light and
|
||||||
dark terminal backgrounds. The mechanism (auto-detect via
|
dark terminal backgrounds. The mechanism (auto-detect via
|
||||||
terminal query, explicit user setting, or both) is an
|
terminal query, explicit user setting, or both) is an
|
||||||
implementation choice, but the outcome is non-negotiable: no
|
implementation choice, but the outcome is non-negotiable: no
|
||||||
dark-on-dark or light-on-light readability failures on either
|
dark-on-dark or light-on-light readability failures on either
|
||||||
background.
|
background.
|
||||||
|
*(Verified 2026-06-22, ADR-0057 — the WCAG-AA contrast gate
|
||||||
|
(NFR-5) runs over **both** themes, so the legibility outcome is
|
||||||
|
enforced on light and dark. Selection mechanism is `--theme` +
|
||||||
|
`COLORFGBG` auto-detect (OSC-11 querying deferred).)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# ADR-website-001: Public website and documentation site
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted (2026-06-04). Implementation plan:
|
||||||
|
[`docs/website/plans/20260604-website-implementation-plan.md`](../plans/20260604-website-implementation-plan.md).
|
||||||
|
|
||||||
|
> History: drafted as ADR-0042, renamed to ADR-0044 (each collided with a
|
||||||
|
> number `main` had independently assigned — H1a took 0042, compound-PK FK
|
||||||
|
> took 0043, then relationship-visualization took 0044). Moved to the
|
||||||
|
> website ADR namespace (`docs/website/adr/`, id **ADR-website-001**) on
|
||||||
|
> 2026-06-10 to end the recurring cross-branch number collision: website
|
||||||
|
> decision records now draw from their own dated sequence and never the
|
||||||
|
> main global ADR pool (see ADR-0000 "Numbering discipline"). Content is
|
||||||
|
> unchanged from the original draft.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
RDBMS Playground is nearing its first public release and needs a public
|
||||||
|
website that does two jobs: **attract** — a landing page that shows the
|
||||||
|
playground in action — and **document** — a thorough, canonical reference
|
||||||
|
for everything the playground can do.
|
||||||
|
|
||||||
|
The documentation-heavy surface is already implemented and verified (full
|
||||||
|
simple- and advanced-mode command set, the ten-type vocabulary,
|
||||||
|
relationships, constraints, indexes, EXPLAIN, undo/history/replay, projects,
|
||||||
|
export/import, the teaching echo, clipboard, friendly errors, tab completion
|
||||||
|
and syntax highlighting; 2151 tests passing at the time of writing). The
|
||||||
|
site is therefore largely a presentation-and-writing effort, not a
|
||||||
|
wait-for-features one. A grounded inventory of what is shippable now lives
|
||||||
|
in the implementation plan.
|
||||||
|
|
||||||
|
Several choices here had no canonical default; they were settled during a
|
||||||
|
planning + `/runda` pass with the user and are recorded below.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. **Stack — Astro 6 + Starlight + Tailwind v4.** Astro's content-first,
|
||||||
|
zero-JS-by-default model with the Starlight docs theme fits a
|
||||||
|
marketing-landing-plus-heavy-docs site better than the alternative
|
||||||
|
considered, SvelteKit + Tailwind (the usual go-to here). Interactive
|
||||||
|
pieces are added as Astro islands (Svelte or vanilla), so Svelte is still
|
||||||
|
available where it earns its place. Tailwind v4 is wired via the official
|
||||||
|
`@tailwindcss/vite` plugin; the `@astrojs/starlight-tailwind` plugin
|
||||||
|
bridges Tailwind with Starlight's theming.
|
||||||
|
|
||||||
|
2. **Demo medium — asciinema.** Showcase sequences are recorded as
|
||||||
|
asciinema `.cast` files (text-based, small, faithful to the full
|
||||||
|
alternate-screen render) and embedded with `asciinema-player`. The same
|
||||||
|
casts are reused inline in the docs — one recording format serves both
|
||||||
|
the landing page and documentation enrichment. Recordings are produced by
|
||||||
|
a **scripted-input driver** that types commands into a real PTY with
|
||||||
|
viewer-friendly pacing; the app's own `history.log` **replay** (ADR-0034)
|
||||||
|
re-executes commands without typing animation or pacing and is therefore
|
||||||
|
suitable only for state-deterministic docs snippets, not the hero demo.
|
||||||
|
|
||||||
|
3. **In-page WASM playground — deferred** (OOS: **deferred**, not rejected).
|
||||||
|
A live, type-it-yourself playground compiled from the Rust app to
|
||||||
|
WebAssembly is desirable but is a multi-week sub-project, so it does not
|
||||||
|
block the site. The demo section is designed with a stable seam (a single
|
||||||
|
`Demo` component contract) so a WASM playground island can replace the
|
||||||
|
asciinema player later with no change to call sites. Recorded boundary
|
||||||
|
for that future work:
|
||||||
|
- **Portable core (runs on `wasm32-unknown-unknown` largely as-is):**
|
||||||
|
`src/dsl/*` (parser, types, grammar, walker), the pure `App::update()`,
|
||||||
|
`ui.rs`, `theme.rs`, `friendly/*`, output rendering; an in-memory DB
|
||||||
|
path already exists (`Connection::open_in_memory()`). `rusqlite`
|
||||||
|
compiles to the browser target via its `ffi-sqlite-wasm-rs` feature.
|
||||||
|
- **Native edge needing `cfg`-gated browser replacements:** the
|
||||||
|
multi-thread Tokio runtime + the dedicated DB **worker thread**
|
||||||
|
(ADR-0010) → current-thread/in-line async; `crossterm` terminal +
|
||||||
|
event-stream → a browser backend (e.g. Ratzilla's DOM/Canvas) + DOM
|
||||||
|
events; `arboard`, `zip`, file persistence (ADR-0015), file logging;
|
||||||
|
and the rusqlite **backup-API** undo (ADR-0006) → a SQL dump/restore.
|
||||||
|
When taken up, this becomes its own ADR + iteration plan.
|
||||||
|
|
||||||
|
4. **Hosting — portable static build; Cloudflare is the target (decided
|
||||||
|
2026-06-11).** Astro 6 builds to static HTML/CSS with no adapter, so the
|
||||||
|
output deploys equally to Cloudflare, Vercel, Netlify, or GitHub Pages — we
|
||||||
|
stay uncoupled from any one host. **Planned pipeline: Gitea Actions →
|
||||||
|
Cloudflare.** Cloudflare now steers new projects to **Workers (static
|
||||||
|
assets)** over Pages; either serves the static `dist/` and needs no Astro
|
||||||
|
adapter (the `@astrojs/cloudflare` adapter is only for SSR, which the site
|
||||||
|
does not use). The future in-page WASM playground (§3), if it needs
|
||||||
|
COOP/COEP headers, can get them from Cloudflare `_headers`. **CI implemented
|
||||||
|
2026-06-15** (`.gitea/workflows/website.yaml`): a push touching `website/**`
|
||||||
|
builds the static site with pnpm and deploys `dist/` to the Cloudflare Pages
|
||||||
|
project **`relplay`** via `wrangler` (Direct Upload — no Git integration).
|
||||||
|
The `--branch` label selects environment against the project's production
|
||||||
|
branch (`main`): **`main` → production (`relplay.org`)**, **`website` →
|
||||||
|
preview (`website.relplay.pages.dev`)**, with `staging.relplay.org` attachable
|
||||||
|
to the `website` branch alias. The crate's CI gate (`ci.yaml`) skips
|
||||||
|
website-only pushes; the build is pure-Node (the `.cast` files are committed,
|
||||||
|
so no cargo). Secrets: `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`.
|
||||||
|
|
||||||
|
5. **Repo topology — monorepo.** The site lives under `website/` in the
|
||||||
|
playground repo; the crate stays at the repo root. The repo as a whole
|
||||||
|
moves to its public home later; site and crate travel together.
|
||||||
|
|
||||||
|
6. **Canonical docs home — the website.** User-facing documentation lives on
|
||||||
|
the site. In-repo `docs/` keeps ADRs, handoffs, and development notes;
|
||||||
|
`docs/simple-mode-limitations.md` (requirement DOC1) was a development aid
|
||||||
|
and now *feeds* the site's content rather than competing with it. The
|
||||||
|
sharing recipes promised by requirement E2 become a docs page.
|
||||||
|
|
||||||
|
7. **Documentation scope and conventions.** Document the **full supported
|
||||||
|
feature set**. Any capability not yet fully implemented (a small minority
|
||||||
|
— e.g. multi-line input, query cancellation, `seed`, `m:n` convenience,
|
||||||
|
ER-diagram export, the `show tables`/`relationships`/`indexes` family) is
|
||||||
|
either omitted or carries a clear **"planned / not yet available"**
|
||||||
|
callout — never presented as shipped. Two wording rules bind all
|
||||||
|
user-facing copy:
|
||||||
|
- **No engine name** (SQLite/STRICT/rusqlite/PRAGMA) — continues the
|
||||||
|
user-facing posture of ADR-0002; copy says "the database"/"the engine".
|
||||||
|
- **No "DSL"** — it is internal jargon. The two input modes are **simple
|
||||||
|
mode** (the playground's keyword command language) and **advanced
|
||||||
|
mode** (SQL).
|
||||||
|
|
||||||
|
8. **Install documentation — two mechanisms.** The install page documents
|
||||||
|
**prebuilt release binaries** (self-hosted download — not GitHub
|
||||||
|
Releases, since the repo will move) and **package managers**. Both can be
|
||||||
|
written now against the planned mechanisms; concrete download URLs slot in
|
||||||
|
at release. (Distribution items D1–D3 in `requirements.md` remain the
|
||||||
|
tracking home for the release tooling itself.)
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The site can ship on the strength of already-implemented features; it is
|
||||||
|
gated on writing and recording, not on finishing the app.
|
||||||
|
- One recording format (asciinema `.cast`) serves both marketing and docs,
|
||||||
|
and is reusable as the app evolves (re-run the script, re-record).
|
||||||
|
- The WASM playground is preserved as a real future option without holding
|
||||||
|
up launch; the demo seam keeps the upgrade cheap.
|
||||||
|
- A single canonical docs home removes the divergence risk of maintaining
|
||||||
|
user docs in two places.
|
||||||
|
- Website build choices (Decisions 1, 2, 4, 5) are recorded here for
|
||||||
|
traceability but do not, by themselves, warrant further ADRs; only
|
||||||
|
app-architecture decisions (notably the future WASM port) will.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **In-page WASM playground** — *deferred* (see Decision 3); revisit as its
|
||||||
|
own ADR + iteration plan.
|
||||||
|
- **Hosted/SaaS playground or a server-backed doc CMS** — *rejected*: a
|
||||||
|
static site fully satisfies the need, consistent with ADR-0007's
|
||||||
|
no-hosted-publishing stance. Revisit only if real demand emerges.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Website Architecture Decision Records
|
||||||
|
|
||||||
|
Decision records for the **public website + documentation site** subproject
|
||||||
|
(the Astro/Starlight site under `website/`). These are kept in their own
|
||||||
|
namespace, separate from the project-wide ADRs in
|
||||||
|
[`docs/adr/`](../../adr/README.md), so website decisions never compete with
|
||||||
|
the main global ADR sequence for numbers — see
|
||||||
|
[ADR-0000 "Numbering discipline"](../../adr/0000-record-architecture-decisions.md).
|
||||||
|
|
||||||
|
**Numbering.** Files are named `<date>-adr-website-<NNN>.md` and referenced
|
||||||
|
in prose as `ADR-website-NNN`. The `<date>` (the ADR's accepted/created day,
|
||||||
|
`YYYYMMDD`) plus the `website` segment keeps the namespace disjoint from
|
||||||
|
`main`'s integers. Assign the next free `NNN` from this index. Every ADR
|
||||||
|
change updates this index in the same edit (the ADR-0000 index-upkeep rule
|
||||||
|
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); **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,198 @@
|
|||||||
|
# Plan: public website and documentation site
|
||||||
|
|
||||||
|
**Date:** 2026-06-04 · **Status:** ready to build
|
||||||
|
|
||||||
|
Decisions for this work are recorded in
|
||||||
|
[ADR-website-001](../adr/20260604-adr-website-001.md): Astro 6 +
|
||||||
|
Starlight + Tailwind v4; asciinema demos reusable in docs; the in-page WASM
|
||||||
|
playground deferred behind a stable demo seam; portable static hosting
|
||||||
|
(Vercel target); monorepo (`website/`); website is the canonical docs home;
|
||||||
|
full-feature-set docs with "planned" callouts; install docs cover prebuilt
|
||||||
|
binaries + package managers. This plan is the *how*.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
The site lives under `website/` in this repo; the crate stays at the root.
|
||||||
|
|
||||||
|
```
|
||||||
|
website/
|
||||||
|
├── package.json # pnpm; astro, @astrojs/starlight, tailwind v4
|
||||||
|
├── astro.config.mjs # Starlight integration + sidebar nav
|
||||||
|
├── src/
|
||||||
|
│ ├── pages/index.astro # marketing landing (custom, not Starlight)
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── Demo.astro # demo SLOT — the WASM-playground seam
|
||||||
|
│ │ └── Cast.astro # asciinema-player island wrapper
|
||||||
|
│ ├── content/docs/ # Starlight MDX docs (the bulk of the work)
|
||||||
|
│ └── styles/ # shared Tailwind + Starlight theme tokens
|
||||||
|
├── public/casts/ # recorded *.cast asciinema files
|
||||||
|
├── README.md # local dev + recording recipe
|
||||||
|
└── STYLE.md # living documentation style guide
|
||||||
|
```
|
||||||
|
|
||||||
|
Root `.gitignore` gains `website/node_modules`, `website/dist`,
|
||||||
|
`website/.astro`.
|
||||||
|
|
||||||
|
## Documentation inventory (grounded — drives Phase D scope)
|
||||||
|
|
||||||
|
Built from `docs/handoff/55–59`, `docs/adr/*`, the command REGISTRY
|
||||||
|
(`src/dsl/grammar/mod.rs:603`, which also auto-assembles in-app `help`), the
|
||||||
|
`Command` enum (`src/dsl/command.rs:149`), and
|
||||||
|
`src/friendly/strings/en-US.yaml` — **not** the coarse `requirements.md`
|
||||||
|
checkboxes (handoff-59 found those ~46% mis-marked; they now use a `[/]`
|
||||||
|
"partial" legend — trust the code, not the marker). Refreshed **2026-06-09
|
||||||
|
after merging `main`**, which added the show-list/detail, `help <command>`,
|
||||||
|
and compound-PK FK surface (see the dedicated bullet below). Test state:
|
||||||
|
**2193 passing, 0 failing, 1 ignored.**
|
||||||
|
|
||||||
|
**SHIPPED — document as-is (the doc core):**
|
||||||
|
- Input modes: simple, advanced (SQL), `:` one-shot escape, `mode` command,
|
||||||
|
per-project mode restore (ADR-0003/0015/0037).
|
||||||
|
- Full simple-mode command surface: create/drop table; add/drop/rename/
|
||||||
|
change column; add/drop 1:n relationship (named, ON DELETE/UPDATE
|
||||||
|
CASCADE/SET NULL/RESTRICT, `--create-fk`); add/drop index; insert/update/
|
||||||
|
delete (required WHERE + `--all-rows`; complex WHERE: AND/OR/NOT, LIKE,
|
||||||
|
IS NULL, IN, BETWEEN); show table/data (where/limit); add/drop constraint;
|
||||||
|
explain (ADR-0009/0013/0014/0025/0026/0028/0029).
|
||||||
|
- Full advanced-mode SQL: CREATE/DROP/ALTER TABLE (cols, constraints, inline
|
||||||
|
+ table FKs, rename, alter-column-type), CREATE [UNIQUE]/DROP INDEX; SELECT
|
||||||
|
(joins, GROUP BY/HAVING, ORDER BY, LIMIT/OFFSET, UNION/INTERSECT/EXCEPT,
|
||||||
|
WITH [RECURSIVE] CTEs); INSERT (multi-row, ON CONFLICT, RETURNING)/UPDATE/
|
||||||
|
DELETE; full expression grammar incl. CASE, CAST, curated functions;
|
||||||
|
EXPLAIN over SQL (ADR-0030–0039).
|
||||||
|
- Types: all ten, advanced-mode SQL aliases, serial/shortid auto-fill
|
||||||
|
(ADR-0005/0011/0017/0018). Constraints: PK incl. compound, NOT NULL,
|
||||||
|
UNIQUE, CHECK, DEFAULT, FK (ADR-0029/0013/0035).
|
||||||
|
- Undo/redo, history.log journal, replay, `--resume`, `--no-undo`
|
||||||
|
(ADR-0006/0034). Projects & storage: project.yaml + CSV + history.log,
|
||||||
|
save/save as/load/new/rebuild, temp projects, `--data-dir`
|
||||||
|
(ADR-0004/0015). Export/import (zip), clipboard copy/copy all/copy last
|
||||||
|
(ADR-0007/0041).
|
||||||
|
- Friendly errors (all five categories) + validity indicator
|
||||||
|
(ADR-0019/0027), DSL→SQL teaching echo (ADR-0038), EXPLAIN plan tree
|
||||||
|
(ADR-0028), box-drawing tables (ADR-0016), tab completion + syntax
|
||||||
|
highlighting + in-line editing (ADR-0022).
|
||||||
|
- **Added by the `main` merge (2026-06-09):** schema-inspection commands
|
||||||
|
`show tables` / `show relationships` / `show indexes` and the singular
|
||||||
|
`show relationship <name>` / `show index <name>` detail views (V5/V5a);
|
||||||
|
`help [<command>]` per-command detail + `help types` + general reference
|
||||||
|
(H3); **compound-primary-key foreign-key references** — DSL
|
||||||
|
`from <P>.(a, b) to <C>.(x, y)` and SQL `FOREIGN KEY (a, b) REFERENCES
|
||||||
|
P(x, y)` (single-column form unchanged) (ADR-0043, T3); friendlier
|
||||||
|
parse-error near-miss messaging (H1a, ADR-0042). These need coverage: a
|
||||||
|
schema-inspection page (the `show` family) and compound-FK examples on the
|
||||||
|
Relationships page.
|
||||||
|
|
||||||
|
**DOCUMENT WITH CAVEAT:** `add unique index` is advanced-only; simple-mode
|
||||||
|
table rename is intentionally absent (rename is `ALTER TABLE … RENAME TO`);
|
||||||
|
`hint` (H2) is still partial; a compound-FK *violation* message names only
|
||||||
|
the first column pair (enforcement is correct — a messaging-only residual).
|
||||||
|
|
||||||
|
**OMIT or MARK "planned":** multi-line input (I1), readline shortcuts (I1b),
|
||||||
|
in-flight cancellation / query timeout (I5/B3), `seed` (SD1), `m:n`
|
||||||
|
convenience (C4), one-step modify relationship (C3a), relationship line-art
|
||||||
|
(V1), ER-diagram export (V3), session-log + Markdown export (V4).
|
||||||
|
|
||||||
|
**Mine verbatim for docs:** `en-US.yaml` `help.app.*`, `help.ddl.*`,
|
||||||
|
`help.data.*`, `help.types_reference`, `parse.usage.*` (one-line syntax
|
||||||
|
templates), `hint.*` — keeps docs and in-app help consistent.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
### A — Scaffold
|
||||||
|
`pnpm create astro@latest` (Starlight template) in `website/`; `astro add
|
||||||
|
tailwind` (Tailwind v4 via `@tailwindcss/vite`); add
|
||||||
|
`@astrojs/starlight-tailwind`. Confirm `pnpm dev` serves and `pnpm build`
|
||||||
|
emits a static `dist/`. Echo build steps for traceability.
|
||||||
|
|
||||||
|
### B — Landing page
|
||||||
|
Custom `src/pages/index.astro` (Starlight owns `/docs/*`). Hero + value prop
|
||||||
|
("learn relational databases by doing"), feature highlights from the
|
||||||
|
inventory, an embedded demo cast above the fold. Use the `frontend-design`
|
||||||
|
skill to avoid generic AI aesthetics; honour NFR-4/5/7 (distinctive design,
|
||||||
|
meaningful colour, light/dark).
|
||||||
|
|
||||||
|
### C — asciinema recording workflow
|
||||||
|
Record real `rdbms-playground` sessions to `public/casts/*.cast` using a
|
||||||
|
**scripted-input driver** (e.g. `asciinema-automation`/autocast, or an
|
||||||
|
expect/doitlive script) for paced, re-recordable demos. Record at a fixed
|
||||||
|
sensible cols×rows; provide light + dark player themes. `Cast.astro` wraps
|
||||||
|
`asciinema-player` as a `client:visible` island; the same component embeds
|
||||||
|
casts inline in docs. Document the recipe in `website/README.md`.
|
||||||
|
(`asciinema` 2.4.0 is installed.)
|
||||||
|
|
||||||
|
### D — Documentation (the bulk)
|
||||||
|
**Five** top-level sidebar sections (autogenerated per directory). The key
|
||||||
|
split: *Using the playground* = the application you drive; *Reference* = the
|
||||||
|
database language you build with.
|
||||||
|
- **Getting started** — install (prebuilt binaries + package managers),
|
||||||
|
first project, simple vs. advanced mode, the example library.
|
||||||
|
- **Using the playground** — command-line options; the assistive editor
|
||||||
|
(completion, syntax highlighting, the `[ERR]`/`[WRN]` validity indicator,
|
||||||
|
hints, in-line editing); the output pane (PageUp/PageDown scrolling — the
|
||||||
|
fuller V4 session-log / Markdown export is *planned*, mark it); projects
|
||||||
|
(save / load / new / rebuild); undo, redo & history (+ replay); export &
|
||||||
|
import (E2 recipes); copy to clipboard; getting help (`help` /
|
||||||
|
`help <command>` / `hint`). (ADR-0003 "app-level commands" + ADR-0022/0027
|
||||||
|
typing assistance + the CLI.)
|
||||||
|
- **Guides** — task walkthroughs.
|
||||||
|
- **Reference** — the database language: Tables, Columns, Relationships,
|
||||||
|
Indexes, Constraints, Inserting & editing data, Querying & inspecting
|
||||||
|
(`show` / `select`), Types, Query plans (EXPLAIN), Errors explained, the
|
||||||
|
simple-command → SQL teaching echo.
|
||||||
|
- **Concepts** — the *why*: projects & storage model, the derived database,
|
||||||
|
how undo works.
|
||||||
|
|
||||||
|
**Surface the assistive editor prominently** — it is a differentiator and
|
||||||
|
most helps beginners: a landing-page card + a Getting-started mention, both
|
||||||
|
linking into *Using the playground*. It is prime asciinema-cast material
|
||||||
|
(completion / validity indicator are motion a still code block can't show).
|
||||||
|
|
||||||
|
Build order: Tier 1 simple-mode reference + types + constraints + input
|
||||||
|
modes + mined help/usage strings → Tier 2 advanced SQL + relationships +
|
||||||
|
project lifecycle + undo/history → Tier 3 teaching echo + EXPLAIN + errors +
|
||||||
|
completion/highlighting → Tier 4 clipboard + hints + editing.
|
||||||
|
|
||||||
|
Conventions live in the **living style guide** `website/STYLE.md` (binding
|
||||||
|
rules from ADR-website-001 §7 — no engine name, **no "DSL"**, "planned" callouts —
|
||||||
|
plus finer conventions and an open-decisions log for depth/splitting/example
|
||||||
|
dataset/etc. as they settle). Sources to mine: `src/dsl/command.rs`,
|
||||||
|
`src/dsl/grammar/*`, the REGISTRY, `en-US.yaml`, `docs/adr/*`,
|
||||||
|
`docs/simple-mode-limitations.md`.
|
||||||
|
|
||||||
|
### E — Hosting & portability
|
||||||
|
Keep the default static build (no adapter); `dist/` deploys to Vercel or any
|
||||||
|
static host. `website/README.md` notes the Vercel preset (root dir
|
||||||
|
`website/`) and the one-line `@astrojs/vercel` switch if SSR is ever needed.
|
||||||
|
|
||||||
|
## Demo seam (WASM hook)
|
||||||
|
|
||||||
|
`Demo.astro` exposes a stable contract (`{ src, title, height, autoplay }`).
|
||||||
|
At launch it renders `Cast.astro`; later a `Playground.astro` WASM island
|
||||||
|
swaps in behind the same props on the landing page and in docs, with zero
|
||||||
|
call-site changes. Boundary details are in ADR-website-001 §3.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm dev` renders landing + docs; `pnpm build` emits a clean static
|
||||||
|
`dist/` with no errors/warnings.
|
||||||
|
- Landing shows at least one playing `.cast`; the same component renders a
|
||||||
|
cast inline in a docs page (proves reuse).
|
||||||
|
- Starlight link-check passes (broken internal links fail the build).
|
||||||
|
- Docs grep clean of forbidden terms: **no "DSL"**, no engine name.
|
||||||
|
- A `dist/` static deploy works on Vercel (manual import) — confirms
|
||||||
|
portability. (No CI gate yet, per ADR-website-001 §4.)
|
||||||
|
|
||||||
|
## Notes / recommendations (non-blocking)
|
||||||
|
|
||||||
|
- **Doc drift:** consider generating the command reference from source (the
|
||||||
|
`help` REGISTRY / `en-US.yaml`) rather than hand-writing all of it.
|
||||||
|
- **Accessibility/SEO:** pair each hero `.cast` with a text transcript or the
|
||||||
|
equivalent docs snippet.
|
||||||
|
- **Branding/domain & analytics** unspecified — assume none until decided;
|
||||||
|
no third-party trackers without consent.
|
||||||
|
- Tailwind v4 + Starlight have occasional theme-token friction; the
|
||||||
|
`@astrojs/starlight-tailwind` plugin is the supported bridge.
|
||||||
|
- Starlight ships local search (Pagefind) by default.
|
||||||
|
- No `README.md` exists at the repo root yet — wanted for the destination
|
||||||
|
repo; out of this plan's core scope but flagged.
|
||||||
Executable
+141
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# adr-reserve.sh — atomically reserve the next main-sequence ADR number.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS
|
||||||
|
# A contiguous ADR integer needs a single allocator, but git branches have
|
||||||
|
# no shared allocator until they merge — which is why two parallel branches
|
||||||
|
# that each invent "the next number" collide. This script makes `main` the
|
||||||
|
# allocator and an atomic `git push` the lock:
|
||||||
|
#
|
||||||
|
# 1. read an append-only ledger (docs/adr/RESERVATIONS.log) + the existing
|
||||||
|
# NNNN-*.md ADRs on main; next = highest + 1;
|
||||||
|
# 2. append "<NNNN> <slug> <date> <title>" to the ledger, commit, PUSH;
|
||||||
|
# 3. if the push is rejected because main moved (someone reserved first),
|
||||||
|
# re-fetch, recompute, and retry.
|
||||||
|
#
|
||||||
|
# A push to a ref is a compare-and-swap, so the remote `main` ref is the
|
||||||
|
# mutex and step 3 is the retry-on-contention. The number is therefore
|
||||||
|
# stable from the moment this returns — safe to cite in commit messages
|
||||||
|
# (which are immutable) and in other ADRs from the very first commit.
|
||||||
|
# (Decision recorded in the dev-workflow ADR.)
|
||||||
|
#
|
||||||
|
# It runs from ANY branch / worktree / directory inside the repo and never
|
||||||
|
# touches your working tree: the allocation happens in a throwaway worktree
|
||||||
|
# on a detached origin/main.
|
||||||
|
#
|
||||||
|
# PREREQUISITE
|
||||||
|
# You must be allowed to push directly to `main` (the repo owner is
|
||||||
|
# whitelisted in the branch-protection rule for exactly this). Everything
|
||||||
|
# substantive still goes through a PR; this one append-only ledger line is
|
||||||
|
# the sole sanctioned direct push. If protection rejects the push, the
|
||||||
|
# script aborts with a clear message rather than spinning.
|
||||||
|
#
|
||||||
|
# USAGE
|
||||||
|
# scripts/adr-reserve.sh <slug> [title...]
|
||||||
|
# scripts/adr-reserve.sh clause-concept-hints "Clause-concept hints"
|
||||||
|
#
|
||||||
|
# OUTPUT
|
||||||
|
# The reserved zero-padded number on stdout (e.g. 0058); progress on stderr.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
log() { printf ' reserve-adr: %s\n' "$*" >&2; }
|
||||||
|
die() { printf 'reserve-adr: ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
REMOTE="origin"
|
||||||
|
BRANCH="main"
|
||||||
|
LEDGER="docs/adr/RESERVATIONS.log"
|
||||||
|
MAX_ATTEMPTS=20
|
||||||
|
|
||||||
|
[ $# -ge 1 ] || die "usage: adr-reserve.sh <slug> [title...]"
|
||||||
|
slug="$1"; shift
|
||||||
|
title="${*:-}"
|
||||||
|
|
||||||
|
# Slug must match the NNNN-<slug>.md filename convention.
|
||||||
|
[[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] \
|
||||||
|
|| die "slug must be lowercase-kebab-case (got: '$slug')"
|
||||||
|
|
||||||
|
root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git repo"
|
||||||
|
|
||||||
|
log "allocating next ADR number for '$slug' against $REMOTE/$BRANCH"
|
||||||
|
git -C "$root" fetch --quiet "$REMOTE" "$BRANCH" || die "git fetch $REMOTE $BRANCH failed"
|
||||||
|
|
||||||
|
# Throwaway detached worktree on origin/main — isolated from your working tree.
|
||||||
|
tmp_base="$(mktemp -d)"
|
||||||
|
wt="$tmp_base/wt"
|
||||||
|
# shellcheck disable=SC2329 # invoked indirectly via `trap cleanup EXIT`
|
||||||
|
cleanup() {
|
||||||
|
git -C "$root" worktree remove --force "$wt" >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$tmp_base"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
git -C "$root" worktree add --quiet --detach "$wt" "$REMOTE/$BRANCH" \
|
||||||
|
|| die "could not create temp worktree at $wt"
|
||||||
|
|
||||||
|
# Highest number across existing top-level ADR files and the ledger, + 1.
|
||||||
|
compute_next() {
|
||||||
|
local max=0 n base
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in "$wt"/docs/adr/[0-9][0-9][0-9][0-9]-*.md; do
|
||||||
|
base="$(basename "$f")"; n=$((10#${base%%-*}))
|
||||||
|
(( n > max )) && max=$n
|
||||||
|
done
|
||||||
|
if [ -f "$wt/$LEDGER" ]; then
|
||||||
|
while read -r num _; do
|
||||||
|
[[ "$num" =~ ^[0-9]{4}$ ]] || continue
|
||||||
|
n=$((10#$num)); (( n > max )) && max=$n
|
||||||
|
done < "$wt/$LEDGER"
|
||||||
|
fi
|
||||||
|
printf '%04d' $(( max + 1 ))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Idempotence: if this slug is already reserved, report it and stop.
|
||||||
|
if [ -f "$wt/$LEDGER" ]; then
|
||||||
|
existing="$(awk -v s="$slug" '$2==s {print $1; exit}' "$wt/$LEDGER" || true)"
|
||||||
|
if [ -n "${existing:-}" ]; then
|
||||||
|
log "'$slug' is already reserved as ADR $existing — nothing to do"
|
||||||
|
echo "$existing"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
attempt=0
|
||||||
|
while (( attempt < MAX_ATTEMPTS )); do
|
||||||
|
attempt=$(( attempt + 1 ))
|
||||||
|
git -C "$wt" fetch --quiet "$REMOTE" "$BRANCH"
|
||||||
|
git -C "$wt" reset --hard --quiet "$REMOTE/$BRANCH"
|
||||||
|
|
||||||
|
num="$(compute_next)"
|
||||||
|
|
||||||
|
# Dry run: report the number we WOULD reserve and stop (no commit/push).
|
||||||
|
# Useful for a preview and for testing the read/allocation path.
|
||||||
|
if [ -n "${ADR_RESERVE_DRY_RUN:-}" ]; then
|
||||||
|
log "DRY RUN: would reserve ADR $num for '$slug' (no push)"
|
||||||
|
echo "$num"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$wt/docs/adr"
|
||||||
|
printf '%s %s %s %s\n' "$num" "$slug" "$(date +%F)" "$title" >> "$wt/$LEDGER"
|
||||||
|
git -C "$wt" add "$LEDGER"
|
||||||
|
git -C "$wt" commit --quiet -m "docs(adr): reserve $num for $slug"
|
||||||
|
|
||||||
|
log "attempt $attempt: claiming ADR $num …"
|
||||||
|
if push_out="$(git -C "$wt" push "$REMOTE" "HEAD:$BRANCH" 2>&1)"; then
|
||||||
|
log "reserved ADR $num for '$slug' (pushed to $REMOTE/$BRANCH)"
|
||||||
|
echo "$num"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Distinguish a lost race (retryable) from a hard rejection (not).
|
||||||
|
if grep -qiE 'non-fast-forward|fetch first|tip of your .* is behind|stale info' <<<"$push_out"; then
|
||||||
|
log "race lost (main moved) — re-fetching and retrying"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$push_out" >&2
|
||||||
|
die "push to $BRANCH was rejected and this is not a race — likely branch \
|
||||||
|
protection (is the owner whitelisted for direct pushes to $BRANCH?). Aborting."
|
||||||
|
done
|
||||||
|
|
||||||
|
die "gave up after $MAX_ATTEMPTS contended attempts — main is changing very fast?"
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<#
|
||||||
|
.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
|
||||||
|
Verified end-to-end on ARM64 Windows 11 under both Windows PowerShell 5.1
|
||||||
|
and PowerShell 7.6, against the live v0.2.0 release. The x86_64 branch is
|
||||||
|
symmetric (env-based arch detection + a confirmed matching release asset)
|
||||||
|
but has not been run directly. The sibling installer 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'
|
||||||
|
|
||||||
|
# Windows PowerShell 5.1 (the in-box shell) can negotiate only TLS 1.0/1.1 by
|
||||||
|
# default, which modern hosts reject. Opt into TLS 1.2 without disturbing any
|
||||||
|
# protocols already enabled. (No-op on PowerShell 7.)
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol =
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||||
|
|
||||||
|
$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.
|
||||||
|
# Read the architecture from the environment rather than
|
||||||
|
# RuntimeInformation::OSArchitecture: under Windows PowerShell 5.1 that type
|
||||||
|
# resolves from a .NET Framework facade that lacks OSArchitecture, which (with
|
||||||
|
# StrictMode) throws "property cannot be found". PROCESSOR_ARCHITECTURE is set
|
||||||
|
# on every PowerShell version; PROCESSOR_ARCHITEW6432 reports the true OS
|
||||||
|
# architecture when a 32-bit shell runs under WOW64.
|
||||||
|
$osArch = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITEW6432')
|
||||||
|
if (-not $osArch) {
|
||||||
|
$osArch = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITECTURE')
|
||||||
|
}
|
||||||
|
switch ($osArch) {
|
||||||
|
'AMD64' { $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 ..."
|
||||||
|
# -UseBasicParsing: Windows PowerShell 5.1's Invoke-WebRequest otherwise
|
||||||
|
# tries to use the Internet Explorer engine and can fail when it is absent.
|
||||||
|
# (No-op on PowerShell 7.)
|
||||||
|
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $exe
|
||||||
|
Invoke-WebRequest -UseBasicParsing -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"
|
||||||
|
|
||||||
|
# Persist the install dir on the user PATH (for future shells) if missing.
|
||||||
|
$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')
|
||||||
|
}
|
||||||
|
# Also update THIS session's PATH so the command works immediately. The
|
||||||
|
# persisted change only reaches newly-started processes; an already-running
|
||||||
|
# shell (and, depending on how the terminal inherited its environment, even
|
||||||
|
# a freshly-opened one) won't see it until the next sign-out/in.
|
||||||
|
if (($env:Path -split ';') -notcontains $InstallDir) {
|
||||||
|
$env:Path = "$env:Path;$InstallDir"
|
||||||
|
}
|
||||||
|
Write-Host "added $InstallDir to your PATH — '$Bin' works in this window now."
|
||||||
|
Write-Host "for shells already open elsewhere, sign out and back in (or open a fresh one)."
|
||||||
|
}
|
||||||
|
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 "$@"
|
||||||
Executable
+168
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Palette preview + WCAG contrast + CIEDE2000 perceptual audit.
|
||||||
|
|
||||||
|
A dev tool for working on the colour palette (`src/theme.rs`). It reads
|
||||||
|
the live `dark()` / `light()` constructors so it never drifts from the
|
||||||
|
code, renders a true-colour swatch + sample for every palette entry on
|
||||||
|
the theme background, prints the WCAG-AA contrast ratio, and reports the
|
||||||
|
pairwise CIEDE2000 (ΔE2000) distance between the syntax-token colours so
|
||||||
|
near-duplicates (distinct hex, indistinguishable on screen) are obvious.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
scripts/palette-preview.py
|
||||||
|
scripts/palette-preview.py dark:tok_type=F58AAE light:tok_flag=7A5C00
|
||||||
|
|
||||||
|
Each `theme:key=HEX` argument previews a change without editing the
|
||||||
|
source — handy for trying candidate colours. The gates mirrored here are
|
||||||
|
enforced for real by the tests in `src/theme.rs` (NFR-5/NFR-7, ADR-0057):
|
||||||
|
text foregrounds must clear 4.5:1; token pairs must clear ΔE2000 15.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
THEME_RS = os.path.join(os.path.dirname(__file__), "..", "src", "theme.rs")
|
||||||
|
|
||||||
|
# --- WCAG contrast -----------------------------------------------------
|
||||||
|
def _lin(c):
|
||||||
|
c = c / 255.0
|
||||||
|
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
||||||
|
|
||||||
|
def _luminance(rgb):
|
||||||
|
r, g, b = rgb
|
||||||
|
return 0.2126 * _lin(r) + 0.7152 * _lin(g) + 0.0722 * _lin(b)
|
||||||
|
|
||||||
|
def contrast(fg, bg):
|
||||||
|
a, b = _luminance(fg), _luminance(bg)
|
||||||
|
hi, lo = max(a, b), min(a, b)
|
||||||
|
return (hi + 0.05) / (lo + 0.05)
|
||||||
|
|
||||||
|
# --- sRGB -> CIELAB (D65) + CIEDE2000 ----------------------------------
|
||||||
|
def _f(t):
|
||||||
|
return t ** (1 / 3) if t > 0.008856 else 7.787 * t + 16 / 116
|
||||||
|
|
||||||
|
def rgb_to_lab(rgb):
|
||||||
|
r, g, b = (_lin(c) for c in rgb)
|
||||||
|
x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047
|
||||||
|
y = r * 0.2126 + g * 0.7152 + b * 0.0722
|
||||||
|
z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883
|
||||||
|
fx, fy, fz = _f(x), _f(y), _f(z)
|
||||||
|
return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz))
|
||||||
|
|
||||||
|
def de2000(lab1, lab2):
|
||||||
|
L1, a1, b1 = lab1
|
||||||
|
L2, a2, b2 = lab2
|
||||||
|
avg_Lp = (L1 + L2) / 2
|
||||||
|
C1, C2 = math.hypot(a1, b1), math.hypot(a2, b2)
|
||||||
|
avg_C = (C1 + C2) / 2
|
||||||
|
G = 0.5 * (1 - math.sqrt(avg_C ** 7 / (avg_C ** 7 + 25 ** 7))) if avg_C > 0 else 0
|
||||||
|
a1p, a2p = (1 + G) * a1, (1 + G) * a2
|
||||||
|
C1p, C2p = math.hypot(a1p, b1), math.hypot(a2p, b2)
|
||||||
|
avg_Cp = (C1p + C2p) / 2
|
||||||
|
def hp(ap, b):
|
||||||
|
if ap == 0 and b == 0:
|
||||||
|
return 0
|
||||||
|
ang = math.degrees(math.atan2(b, ap))
|
||||||
|
return ang + 360 if ang < 0 else ang
|
||||||
|
h1p, h2p = hp(a1p, b1), hp(a2p, b2)
|
||||||
|
dLp, dCp = L2 - L1, C2p - C1p
|
||||||
|
if C1p * C2p == 0:
|
||||||
|
dhp = 0
|
||||||
|
elif abs(h2p - h1p) <= 180:
|
||||||
|
dhp = h2p - h1p
|
||||||
|
elif h2p - h1p > 180:
|
||||||
|
dhp = h2p - h1p - 360
|
||||||
|
else:
|
||||||
|
dhp = h2p - h1p + 360
|
||||||
|
dHp = 2 * math.sqrt(C1p * C2p) * math.sin(math.radians(dhp) / 2)
|
||||||
|
if C1p * C2p == 0:
|
||||||
|
avg_hp = h1p + h2p
|
||||||
|
elif abs(h1p - h2p) <= 180:
|
||||||
|
avg_hp = (h1p + h2p) / 2
|
||||||
|
elif h1p + h2p < 360:
|
||||||
|
avg_hp = (h1p + h2p + 360) / 2
|
||||||
|
else:
|
||||||
|
avg_hp = (h1p + h2p - 360) / 2
|
||||||
|
T = (1 - 0.17 * math.cos(math.radians(avg_hp - 30))
|
||||||
|
+ 0.24 * math.cos(math.radians(2 * avg_hp))
|
||||||
|
+ 0.32 * math.cos(math.radians(3 * avg_hp + 6))
|
||||||
|
- 0.20 * math.cos(math.radians(4 * avg_hp - 63)))
|
||||||
|
d_ro = 30 * math.exp(-((avg_hp - 275) / 25) ** 2)
|
||||||
|
Rc = 2 * math.sqrt(avg_Cp ** 7 / (avg_Cp ** 7 + 25 ** 7)) if avg_Cp > 0 else 0
|
||||||
|
Sl = 1 + (0.015 * (avg_Lp - 50) ** 2) / math.sqrt(20 + (avg_Lp - 50) ** 2)
|
||||||
|
Sc, Sh = 1 + 0.045 * avg_Cp, 1 + 0.015 * avg_Cp * T
|
||||||
|
Rt = -math.sin(math.radians(2 * d_ro)) * Rc
|
||||||
|
return math.sqrt((dLp / Sl) ** 2 + (dCp / Sc) ** 2 + (dHp / Sh) ** 2
|
||||||
|
+ Rt * (dCp / Sc) * (dHp / Sh))
|
||||||
|
|
||||||
|
# --- parse the live palette from src/theme.rs --------------------------
|
||||||
|
def parse_palette(path):
|
||||||
|
text = open(path, encoding="utf-8").read()
|
||||||
|
field = re.compile(
|
||||||
|
r"(\w+):\s*Color::Rgb\(0x([0-9A-Fa-f]{2}),\s*0x([0-9A-Fa-f]{2}),\s*0x([0-9A-Fa-f]{2})\)"
|
||||||
|
)
|
||||||
|
def block(start, end):
|
||||||
|
s = text.index(start)
|
||||||
|
e = text.index(end, s)
|
||||||
|
return {m.group(1): (m.group(2) + m.group(3) + m.group(4)).upper()
|
||||||
|
for m in field.finditer(text[s:e])}
|
||||||
|
return {"dark": block("fn dark()", "fn light()"),
|
||||||
|
"light": block("fn light()", "fn highlight_class_color")}
|
||||||
|
|
||||||
|
def h(s):
|
||||||
|
return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4))
|
||||||
|
|
||||||
|
def fg(rgb):
|
||||||
|
r, g, b = rgb
|
||||||
|
return f"\x1b[38;2;{r};{g};{b}m"
|
||||||
|
|
||||||
|
def bg(rgb):
|
||||||
|
r, g, b = rgb
|
||||||
|
return f"\x1b[48;2;{r};{g};{b}m"
|
||||||
|
|
||||||
|
R = "\x1b[0m"
|
||||||
|
TOKENS = ["tok_keyword", "tok_identifier", "tok_type", "tok_number",
|
||||||
|
"tok_string", "tok_flag", "tok_function"]
|
||||||
|
NONTEXT = {"border", "border_advanced"}
|
||||||
|
SAMPLE = {
|
||||||
|
"tok_keyword": "create table", "tok_identifier": "Customers",
|
||||||
|
"tok_type": "serial", "tok_number": "42", "tok_string": "'hello'",
|
||||||
|
"tok_flag": "--all-rows", "tok_function": "count(", "tok_punct": ", ;",
|
||||||
|
"tok_error": "bad", "fg": "body text", "muted": "(none yet)",
|
||||||
|
"system": "done.", "error": "error: nope", "warning": "[WRN] slow",
|
||||||
|
"plan_efficient": "SEARCH idx", "mode_simple": "SIMPLE",
|
||||||
|
"mode_advanced": "ADVANCED", "border": "──────", "border_advanced": "──────",
|
||||||
|
}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
themes = parse_palette(THEME_RS)
|
||||||
|
for arg in sys.argv[1:]:
|
||||||
|
th, rest = arg.split(":")
|
||||||
|
k, v = rest.split("=")
|
||||||
|
themes[th][k] = v.upper()
|
||||||
|
for tn, t in themes.items():
|
||||||
|
B = h(t["bg"])
|
||||||
|
print(f"\n{'=' * 64}\n {tn.upper()} THEME (bg #{t['bg']})\n{'=' * 64}")
|
||||||
|
for k, hexv in t.items():
|
||||||
|
if k == "bg":
|
||||||
|
continue
|
||||||
|
c = h(hexv)
|
||||||
|
cr = contrast(c, B)
|
||||||
|
swatch = f"{bg(c)} {R}"
|
||||||
|
text = f"{bg(B)}{fg(c)} {SAMPLE.get(k, k):14}{R}"
|
||||||
|
floor = 3.0 if k in NONTEXT else 4.5
|
||||||
|
warn = "" if cr >= floor else f" !! < {floor}"
|
||||||
|
print(f" {swatch} {text} #{hexv} {cr:5.2f}:1{warn} {k}")
|
||||||
|
print("\n -- token ΔE2000 (<12 hard to distinguish; gate is >=15) --")
|
||||||
|
labs = {k: rgb_to_lab(h(t[k])) for k in TOKENS if k in t}
|
||||||
|
pairs = sorted(
|
||||||
|
(de2000(labs[a], labs[b]), a, b)
|
||||||
|
for i, a in enumerate(labs) for b in list(labs)[i + 1:]
|
||||||
|
)
|
||||||
|
for d, a, b in pairs[:6]:
|
||||||
|
flag = " !! TOO CLOSE" if d < 15 else (" ~ close" if d < 18 else "")
|
||||||
|
print(f" {d:5.1f} {a:14} vs {b:14}{flag}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Render the Homebrew formula for rdbms-playground to stdout.
|
||||||
|
#
|
||||||
|
# Pure function of its inputs — NO network, NO jq/ruby — so it runs unchanged in
|
||||||
|
# the CI job container (node:22-bookworm-slim: bash + coreutils only). Given a
|
||||||
|
# version and the four macOS/Linux asset SHA-256 hashes it prints a complete
|
||||||
|
# formula. The publish.yaml `homebrew-tap` job fetches the hashes from the
|
||||||
|
# release .sha256 sidecars and commits the result into lazyeval/homebrew-tap as
|
||||||
|
# Formula/rdbms-playground.rb.
|
||||||
|
#
|
||||||
|
# The release assets are bare binaries (no archive), so Homebrew stages the
|
||||||
|
# single downloaded file in the build dir and `install` drops it under a stable
|
||||||
|
# name. Windows is intentionally absent — Homebrew has no Windows port (Scoop /
|
||||||
|
# winget cover Windows).
|
||||||
|
#
|
||||||
|
# Usage: render-homebrew-formula.sh <version> <mac-arm> <mac-intel> <linux-arm> <linux-intel>
|
||||||
|
# <version> version, with or without a leading 'v'
|
||||||
|
# <mac-arm> sha256 of aarch64-apple-darwin
|
||||||
|
# <mac-intel> sha256 of x86_64-apple-darwin
|
||||||
|
# <linux-arm> sha256 of aarch64-unknown-linux-musl
|
||||||
|
# <linux-intel> sha256 of x86_64-unknown-linux-musl
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$#" -ne 5 ]; then
|
||||||
|
echo "usage: $0 <version> <mac-arm> <mac-intel> <linux-arm> <linux-intel>" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
version=${1#v}
|
||||||
|
mac_arm=$2
|
||||||
|
mac_intel=$3
|
||||||
|
linux_arm=$4
|
||||||
|
linux_intel=$5
|
||||||
|
|
||||||
|
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/v$version"
|
||||||
|
|
||||||
|
# Ruby interpolations (#{version}, #{bin}) must survive verbatim into the
|
||||||
|
# formula; they contain no '$', so this unquoted heredoc leaves them untouched
|
||||||
|
# and only expands the shell variables below.
|
||||||
|
cat <<EOF
|
||||||
|
# typed: false
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# rdbms-playground — installs the prebuilt release binary for the host
|
||||||
|
# platform. Regenerated for each release by scripts/render-homebrew-formula.sh;
|
||||||
|
# do not edit by hand.
|
||||||
|
class RdbmsPlayground < Formula
|
||||||
|
desc "Cross-platform TUI playground for learning relational databases"
|
||||||
|
homepage "https://relplay.org"
|
||||||
|
version "$version"
|
||||||
|
license any_of: ["MIT", "Apache-2.0"]
|
||||||
|
|
||||||
|
on_macos do
|
||||||
|
on_arm do
|
||||||
|
url "$base/rdbms-playground-v$version-aarch64-apple-darwin"
|
||||||
|
sha256 "$mac_arm"
|
||||||
|
end
|
||||||
|
on_intel do
|
||||||
|
url "$base/rdbms-playground-v$version-x86_64-apple-darwin"
|
||||||
|
sha256 "$mac_intel"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
on_linux do
|
||||||
|
on_arm do
|
||||||
|
url "$base/rdbms-playground-v$version-aarch64-unknown-linux-musl"
|
||||||
|
sha256 "$linux_arm"
|
||||||
|
end
|
||||||
|
on_intel do
|
||||||
|
url "$base/rdbms-playground-v$version-x86_64-unknown-linux-musl"
|
||||||
|
sha256 "$linux_intel"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def install
|
||||||
|
# The release asset is a single bare binary; Homebrew stages it in the
|
||||||
|
# build dir under its (versioned) basename. Install it as a stable name.
|
||||||
|
bin.install Dir["*"].first => "rdbms-playground"
|
||||||
|
end
|
||||||
|
|
||||||
|
test do
|
||||||
|
assert_match "rdbms-playground #{version}", shell_output("#{bin}/rdbms-playground --version")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
EOF
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Render the Scoop manifest for rdbms-playground to stdout.
|
||||||
|
#
|
||||||
|
# Pure function of its inputs — NO network, NO jq/ruby — so it runs unchanged in
|
||||||
|
# the CI job container (node:22-bookworm-slim: bash + coreutils only). Given a
|
||||||
|
# version and the two Windows asset SHA-256 hashes it prints a complete,
|
||||||
|
# schema-valid Scoop manifest. The publish.yaml `scoop-bucket` job fetches the
|
||||||
|
# hashes from the release's .sha256 sidecars and commits the result into the
|
||||||
|
# lazyeval/scoop-bucket repository as rdbms-playground.json.
|
||||||
|
#
|
||||||
|
# Manifest updates are CI-driven (this script, per release), so the manifest
|
||||||
|
# carries `checkver` (so `scoop status` / the community excavator can see when
|
||||||
|
# the bucket lags upstream) but deliberately NO `autoupdate` — our pipeline is
|
||||||
|
# the updater, not Scoop's maintainer tooling.
|
||||||
|
#
|
||||||
|
# Usage: render-scoop-manifest.sh <version> <hash-x64> <hash-arm64>
|
||||||
|
# <version> version, with or without a leading 'v' (e.g. 0.2.0 or v0.2.0)
|
||||||
|
# <hash-x64> sha256 of the x86_64-pc-windows-gnu.exe asset
|
||||||
|
# <hash-arm64> sha256 of the aarch64-pc-windows-gnullvm.exe asset
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$#" -ne 3 ]; then
|
||||||
|
echo "usage: $0 <version> <hash-x64> <hash-arm64>" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Accept either 0.2.0 or v0.2.0; the manifest 'version' field is bare.
|
||||||
|
version=${1#v}
|
||||||
|
hash_x64=$2
|
||||||
|
hash_arm64=$3
|
||||||
|
|
||||||
|
repo="https://git.lazyeval.net/oli/rdbms-playground"
|
||||||
|
base="$repo/releases/download/v$version"
|
||||||
|
|
||||||
|
# The `#/rdbms-playground.exe` fragment tells Scoop to save the versioned asset
|
||||||
|
# under a stable filename, so the `bin` shim resolves regardless of version.
|
||||||
|
url_x64="$base/rdbms-playground-v$version-x86_64-pc-windows-gnu.exe#/rdbms-playground.exe"
|
||||||
|
url_arm64="$base/rdbms-playground-v$version-aarch64-pc-windows-gnullvm.exe#/rdbms-playground.exe"
|
||||||
|
|
||||||
|
# Note: \$.tag_name emits a literal $ (Scoop's JSONPath); the regex uses [0-9.]
|
||||||
|
# rather than \d so the manifest contains no backslashes to escape.
|
||||||
|
cat <<EOF
|
||||||
|
{
|
||||||
|
"version": "$version",
|
||||||
|
"description": "A cross-platform TUI playground for learning relational databases.",
|
||||||
|
"homepage": "https://relplay.org",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"architecture": {
|
||||||
|
"64bit": {
|
||||||
|
"url": "$url_x64",
|
||||||
|
"hash": "$hash_x64"
|
||||||
|
},
|
||||||
|
"arm64": {
|
||||||
|
"url": "$url_arm64",
|
||||||
|
"hash": "$hash_arm64"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bin": "rdbms-playground.exe",
|
||||||
|
"checkver": {
|
||||||
|
"url": "https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground/releases/latest",
|
||||||
|
"jsonpath": "\$.tag_name",
|
||||||
|
"regex": "v([0-9.]+)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
Executable
+82
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verifies adr-reserve.sh against a local file:// origin (no network/keys).
|
||||||
|
# Exercises: number computation, dry-run, real reserve+push, sequential
|
||||||
|
# allocation, idempotence, hard-rejection abort (vs infinite loop), and the
|
||||||
|
# race-vs-abort discriminator.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT="$(cd "$(dirname "$0")" && pwd)/adr-reserve.sh"
|
||||||
|
PASS=0 FAIL=0
|
||||||
|
check() { # <desc> <expected> <actual>
|
||||||
|
if [ "$2" = "$3" ]; then echo "PASS: $1 ($3)"; PASS=$((PASS+1));
|
||||||
|
else echo "FAIL: $1 — expected '$2' got '$3'"; FAIL=$((FAIL+1)); fi
|
||||||
|
}
|
||||||
|
|
||||||
|
T="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$T"' EXIT
|
||||||
|
|
||||||
|
# ---- seed a bare origin with ADRs up to 0057 -------------------------------
|
||||||
|
git init -q --bare "$T/origin.git"
|
||||||
|
git clone -q "$T/origin.git" "$T/seed"
|
||||||
|
git -C "$T/seed" config user.email t@t.io
|
||||||
|
git -C "$T/seed" config user.name tester
|
||||||
|
mkdir -p "$T/seed/docs/adr"
|
||||||
|
echo "# legacy" > "$T/seed/docs/adr/0056-alpha.md"
|
||||||
|
echo "# legacy" > "$T/seed/docs/adr/0057-beta.md"
|
||||||
|
git -C "$T/seed" add -A
|
||||||
|
git -C "$T/seed" commit -qm "seed: ADRs through 0057"
|
||||||
|
git -C "$T/seed" branch -M main
|
||||||
|
git -C "$T/seed" push -q origin main
|
||||||
|
|
||||||
|
# the repo the script runs inside (origin = our bare repo)
|
||||||
|
git clone -q "$T/origin.git" "$T/work"
|
||||||
|
git -C "$T/work" config user.email t@t.io
|
||||||
|
git -C "$T/work" config user.name tester
|
||||||
|
|
||||||
|
run() { ( cd "$T/work" && "$@" ); }
|
||||||
|
|
||||||
|
echo "--- Test 1: dry-run computes 0058 (no push) ---"
|
||||||
|
out="$(run env ADR_RESERVE_DRY_RUN=1 bash "$SCRIPT" feature-one "Feature One" 2>/dev/null)"
|
||||||
|
check "dry-run number" "0058" "$out"
|
||||||
|
git -C "$T/seed" pull -q origin main
|
||||||
|
check "dry-run pushed nothing" "no" "$( [ -f "$T/seed/docs/adr/RESERVATIONS.log" ] && echo yes || echo no )"
|
||||||
|
|
||||||
|
echo "--- Test 2: real reserve → 0058, pushed to origin ---"
|
||||||
|
out="$(run bash "$SCRIPT" feature-one "Feature One" 2>/dev/null)"
|
||||||
|
check "first reserve number" "0058" "$out"
|
||||||
|
git -C "$T/seed" pull -q origin main
|
||||||
|
check "ledger landed on origin" "0058 feature-one" "$(awk 'NR==1{print $1" "$2}' "$T/seed/docs/adr/RESERVATIONS.log")"
|
||||||
|
|
||||||
|
echo "--- Test 3: second slug → 0059 (sequential) ---"
|
||||||
|
out="$(run bash "$SCRIPT" feature-two 2>/dev/null)"
|
||||||
|
check "second reserve number" "0059" "$out"
|
||||||
|
|
||||||
|
echo "--- Test 4: idempotence — re-reserving feature-one returns 0058 ---"
|
||||||
|
out="$(run bash "$SCRIPT" feature-one 2>/dev/null)"
|
||||||
|
check "idempotent re-reserve" "0058" "$out"
|
||||||
|
git -C "$T/seed" pull -q origin main
|
||||||
|
check "no duplicate ledger line" "1" "$(grep -c ' feature-one ' "$T/seed/docs/adr/RESERVATIONS.log")"
|
||||||
|
|
||||||
|
echo "--- Test 5: hard rejection (branch protection) aborts, does NOT loop ---"
|
||||||
|
cat > "$T/origin.git/hooks/pre-receive" <<'HOOK'
|
||||||
|
#!/bin/sh
|
||||||
|
echo "protected branch: push declined" >&2
|
||||||
|
exit 1
|
||||||
|
HOOK
|
||||||
|
chmod +x "$T/origin.git/hooks/pre-receive"
|
||||||
|
set +e
|
||||||
|
run bash "$SCRIPT" feature-three >/dev/null 2>"$T/err"; rc=$?
|
||||||
|
set -e
|
||||||
|
check "abort exit code nonzero" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
|
||||||
|
check "abort cites protection, not a loop" "yes" "$(grep -qi 'not a race\|protection' "$T/err" && echo yes || echo no)"
|
||||||
|
rm -f "$T/origin.git/hooks/pre-receive"
|
||||||
|
|
||||||
|
echo "--- Test 6: race discriminator matches a real non-ff message ---"
|
||||||
|
ff_msg=" ! [rejected] main -> main (non-fast-forward)
|
||||||
|
error: failed to push some refs"
|
||||||
|
check "non-ff treated as race" "yes" \
|
||||||
|
"$(grep -qiE 'non-fast-forward|fetch first|tip of your .* is behind|stale info' <<<"$ff_msg" && echo yes || echo no)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== $PASS passed, $FAIL failed ===="
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
Executable
+92
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Tests for the package-manifest render scripts (Scoop + Homebrew).
|
||||||
|
#
|
||||||
|
# Validates that each render script emits a well-formed manifest for given
|
||||||
|
# inputs, and that the inputs land in the right places. Dependency-light:
|
||||||
|
# requires node (always in the CI image) for JSON parsing; uses jq and ruby for
|
||||||
|
# extra checks when present (both available on the dev box). No network.
|
||||||
|
#
|
||||||
|
# Run: scripts/test-package-renders.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
pass() { echo "ok: $*"; }
|
||||||
|
|
||||||
|
# Distinct dummy hashes so we can assert each lands in the right slot.
|
||||||
|
VER=9.9.9
|
||||||
|
H_WIN_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01
|
||||||
|
H_WIN_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa02
|
||||||
|
H_MAC_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa03
|
||||||
|
H_MAC_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa04
|
||||||
|
H_LIN_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa05
|
||||||
|
H_LIN_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa06
|
||||||
|
|
||||||
|
# ---- Scoop ----------------------------------------------------------------
|
||||||
|
scoop=$tmp/rdbms-playground.json
|
||||||
|
# Pass a leading 'v' to confirm it gets stripped.
|
||||||
|
"$here/render-scoop-manifest.sh" "v$VER" "$H_WIN_X64" "$H_WIN_ARM" > "$scoop"
|
||||||
|
|
||||||
|
node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$scoop" \
|
||||||
|
|| fail "scoop manifest is not valid JSON"
|
||||||
|
pass "scoop manifest parses as JSON"
|
||||||
|
|
||||||
|
# Field-level assertions via node (no jq dependency).
|
||||||
|
check_json() { # <jsexpr> <expected>
|
||||||
|
local got
|
||||||
|
got=$(node -e '
|
||||||
|
const m = JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));
|
||||||
|
process.stdout.write(String(eval(process.argv[2])));
|
||||||
|
' "$scoop" "$1")
|
||||||
|
[ "$got" = "$2" ] || fail "scoop: $1 = '$got', expected '$2'"
|
||||||
|
}
|
||||||
|
check_json 'm.version' "$VER"
|
||||||
|
check_json 'm.bin' "rdbms-playground.exe"
|
||||||
|
check_json 'm.architecture["64bit"].hash' "$H_WIN_X64"
|
||||||
|
check_json 'm.architecture["arm64"].hash' "$H_WIN_ARM"
|
||||||
|
check_json 'm.checkver.jsonpath' '$.tag_name'
|
||||||
|
grep -q "rdbms-playground-v$VER-x86_64-pc-windows-gnu.exe#/rdbms-playground.exe" "$scoop" \
|
||||||
|
|| fail "scoop: x64 url/fragment missing"
|
||||||
|
grep -q "rdbms-playground-v$VER-aarch64-pc-windows-gnullvm.exe#/rdbms-playground.exe" "$scoop" \
|
||||||
|
|| fail "scoop: arm64 url/fragment missing"
|
||||||
|
pass "scoop manifest fields correct"
|
||||||
|
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
jq -e . "$scoop" >/dev/null || fail "scoop: jq rejected the manifest"
|
||||||
|
pass "scoop manifest valid per jq"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- Homebrew -------------------------------------------------------------
|
||||||
|
formula=$tmp/rdbms-playground.rb
|
||||||
|
"$here/render-homebrew-formula.sh" "$VER" "$H_MAC_ARM" "$H_MAC_X64" "$H_LIN_ARM" "$H_LIN_X64" > "$formula"
|
||||||
|
|
||||||
|
if command -v ruby >/dev/null 2>&1; then
|
||||||
|
ruby -c "$formula" >/dev/null || fail "homebrew formula is not valid Ruby"
|
||||||
|
pass "homebrew formula parses as Ruby"
|
||||||
|
else
|
||||||
|
echo "warn: ruby not present — skipping formula syntax check" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Each hash must appear exactly once (right asset → right slot), the version
|
||||||
|
# must be present, and #{version} must survive verbatim for brew's test block.
|
||||||
|
for pair in \
|
||||||
|
"aarch64-apple-darwin:$H_MAC_ARM" \
|
||||||
|
"x86_64-apple-darwin:$H_MAC_X64" \
|
||||||
|
"aarch64-unknown-linux-musl:$H_LIN_ARM" \
|
||||||
|
"x86_64-unknown-linux-musl:$H_LIN_X64"; do
|
||||||
|
target=${pair%%:*}; hash=${pair#*:}
|
||||||
|
grep -q "rdbms-playground-v$VER-$target\"" "$formula" || fail "homebrew: url for $target missing"
|
||||||
|
grep -q "sha256 \"$hash\"" "$formula" || fail "homebrew: sha256 for $target missing"
|
||||||
|
done
|
||||||
|
grep -q 'version "'"$VER"'"' "$formula" || fail "homebrew: version line missing"
|
||||||
|
grep -q 'assert_match "rdbms-playground #{version}"' "$formula" \
|
||||||
|
|| fail "homebrew: test block #{version} interpolation was mangled"
|
||||||
|
grep -q 'rdbms-playground-v9.9.9-x86_64-pc-windows' "$formula" \
|
||||||
|
&& fail "homebrew: formula unexpectedly references a Windows asset"
|
||||||
|
pass "homebrew formula fields correct"
|
||||||
|
|
||||||
|
echo "all render tests passed"
|
||||||
Executable
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Smoke-tests wt-new.sh / wt-rm.sh against a local file:// origin.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
WT_NEW="$here/wt-new.sh"
|
||||||
|
WT_RM="$here/wt-rm.sh"
|
||||||
|
PASS=0 FAIL=0
|
||||||
|
check() { if [ "$2" = "$3" ]; then echo "PASS: $1 ($3)"; PASS=$((PASS+1));
|
||||||
|
else echo "FAIL: $1 — expected '$2' got '$3'"; FAIL=$((FAIL+1)); fi; }
|
||||||
|
exists() { [ -d "$1" ] && echo yes || echo no; }
|
||||||
|
has_branch() { git -C "$T/primary" show-ref --verify --quiet "refs/heads/$1" && echo yes || echo no; }
|
||||||
|
|
||||||
|
T="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$T"' EXIT
|
||||||
|
|
||||||
|
git init -q --bare "$T/origin.git"
|
||||||
|
git clone -q "$T/origin.git" "$T/primary"
|
||||||
|
git -C "$T/primary" config user.email t@t.io
|
||||||
|
git -C "$T/primary" config user.name tester
|
||||||
|
echo seed > "$T/primary/README.md"
|
||||||
|
git -C "$T/primary" add -A
|
||||||
|
git -C "$T/primary" commit -qm seed
|
||||||
|
git -C "$T/primary" branch -M main
|
||||||
|
git -C "$T/primary" push -q origin main
|
||||||
|
|
||||||
|
run() { ( cd "$T/primary" && "$@" ); }
|
||||||
|
target1="$T/primary-worktree-thing-one"
|
||||||
|
target2="$T/primary-worktree-thing-two"
|
||||||
|
|
||||||
|
echo "--- wt-new rejects a non-conventional branch name ---"
|
||||||
|
set +e; run bash "$WT_NEW" my-thing >/dev/null 2>&1; rc=$?; set -e
|
||||||
|
check "bad-prefix rejected" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
|
||||||
|
|
||||||
|
echo "--- wt-new creates branch + sibling worktree ---"
|
||||||
|
out="$(run bash "$WT_NEW" feat/thing-one 2>/dev/null)"
|
||||||
|
check "prints target path" "$target1" "$out"
|
||||||
|
check "worktree dir exists" "yes" "$(exists "$target1")"
|
||||||
|
check "worktree on the new branch" "feat/thing-one" "$(git -C "$target1" rev-parse --abbrev-ref HEAD)"
|
||||||
|
# It must NOT inherit origin/main as upstream (else a bare `git push` errors
|
||||||
|
# with a name-mismatch and suggests the dangerous `HEAD:main`). No upstream
|
||||||
|
# until the first `git push -u` sets the right one.
|
||||||
|
check "new branch has no upstream" "none" "$(git -C "$target1" rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || echo none)"
|
||||||
|
|
||||||
|
echo "--- wt-new refuses to clobber an existing branch ---"
|
||||||
|
set +e; run bash "$WT_NEW" feat/thing-one >/dev/null 2>&1; rc=$?; set -e
|
||||||
|
check "duplicate rejected" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
|
||||||
|
|
||||||
|
# Second worktree, and make the merge states explicit:
|
||||||
|
# thing-one → merged into origin/main (push its tip to main),
|
||||||
|
# thing-two → ahead of main (unmerged), clean.
|
||||||
|
run bash "$WT_NEW" feat/thing-two >/dev/null 2>&1
|
||||||
|
git -C "$target1" commit -q --allow-empty -m "thing-one work"
|
||||||
|
git -C "$target1" push -q origin HEAD:main
|
||||||
|
git -C "$target2" commit -q --allow-empty -m "thing-two work (unmerged)"
|
||||||
|
git -C "$T/primary" fetch -q origin
|
||||||
|
|
||||||
|
echo "--- wt-rm removes ONLY the named worktree (the other is untouched) ---"
|
||||||
|
run bash "$WT_RM" feat/thing-one >/dev/null 2>&1
|
||||||
|
check "named worktree removed" "no" "$(exists "$target1")"
|
||||||
|
check "UNNAMED worktree untouched" "yes" "$(exists "$target2")"
|
||||||
|
check "merged branch deleted" "no" "$(has_branch feat/thing-one)"
|
||||||
|
|
||||||
|
echo "--- wt-rm errors on an unknown branch ---"
|
||||||
|
set +e; run bash "$WT_RM" feat/nope >/dev/null 2>&1; rc=$?; set -e
|
||||||
|
check "unknown branch rejected" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
|
||||||
|
|
||||||
|
echo "--- wt-rm refuses the primary checkout ---"
|
||||||
|
set +e; run bash "$WT_RM" main >/dev/null 2>&1; rc=$?; set -e
|
||||||
|
check "primary refused (nonzero)" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
|
||||||
|
check "primary still present" "yes" "$(exists "$T/primary")"
|
||||||
|
|
||||||
|
echo "--- wt-rm refuses a dirty worktree ---"
|
||||||
|
touch "$target2/untracked-file"
|
||||||
|
set +e; run bash "$WT_RM" feat/thing-two >/dev/null 2>&1; rc=$?; set -e
|
||||||
|
check "dirty refused (nonzero)" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
|
||||||
|
check "dirty worktree kept" "yes" "$(exists "$target2")"
|
||||||
|
rm -f "$target2/untracked-file"
|
||||||
|
|
||||||
|
echo "--- wt-rm removes the worktree but KEEPS an unmerged branch ---"
|
||||||
|
run bash "$WT_RM" feat/thing-two >/dev/null 2>&1
|
||||||
|
check "unmerged worktree removed" "no" "$(exists "$target2")"
|
||||||
|
check "unmerged branch preserved" "yes" "$(has_branch feat/thing-two)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== $PASS passed, $FAIL failed ===="
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# wt-new.sh — start a branch in its own git worktree.
|
||||||
|
#
|
||||||
|
# WHY: every branch is developed in an isolated worktree, never by switching
|
||||||
|
# the primary checkout (two shells in one checkout is how you clobber work).
|
||||||
|
# This creates the branch off an up-to-date `main` and a sibling worktree
|
||||||
|
# named to match the repo's existing convention:
|
||||||
|
#
|
||||||
|
# <parent>/<repo>-worktree-<last-segment-of-branch>
|
||||||
|
# e.g. feat/clause-concept-hints -> ../rdbms-playground-worktree-clause-concept-hints
|
||||||
|
#
|
||||||
|
# USAGE
|
||||||
|
# scripts/wt-new.sh <branch>
|
||||||
|
# scripts/wt-new.sh feat/clause-concept-hints
|
||||||
|
#
|
||||||
|
# Prints the new worktree path; cd there to start working.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
log() { printf ' wt-new: %s\n' "$*" >&2; }
|
||||||
|
die() { printf 'wt-new: ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ $# -eq 1 ] || die "usage: wt-new.sh <branch> (e.g. feat/my-thing)"
|
||||||
|
branch="$1"
|
||||||
|
|
||||||
|
# Conventional prefixes keep branch names aligned with commit types.
|
||||||
|
case "$branch" in
|
||||||
|
feat/*|fix/*|docs/*|chore/*|refactor/*|test/*|ci/*) : ;;
|
||||||
|
*) die "branch should start with feat/ fix/ docs/ chore/ refactor/ test/ ci/ (got: $branch)" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repo"
|
||||||
|
|
||||||
|
# Sibling-of-the-primary-checkout location, matching existing worktrees.
|
||||||
|
main_wt="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
|
||||||
|
parent="$(dirname "$main_wt")"
|
||||||
|
repo="$(basename "$main_wt")"
|
||||||
|
seg="${branch##*/}"
|
||||||
|
target="$parent/${repo}-worktree-${seg}"
|
||||||
|
|
||||||
|
[ -e "$target" ] && die "worktree path already exists: $target"
|
||||||
|
git show-ref --verify --quiet "refs/heads/$branch" \
|
||||||
|
&& die "branch already exists: $branch (use 'git worktree add' to attach a worktree to it)"
|
||||||
|
|
||||||
|
log "fetching origin/main"
|
||||||
|
git fetch --quiet origin main || die "git fetch origin main failed"
|
||||||
|
|
||||||
|
log "creating branch '$branch' + worktree at $target"
|
||||||
|
# `--no-track`: branch off origin/main for a fresh base, but DON'T inherit
|
||||||
|
# origin/main as the upstream. Without this, branching off a remote-tracking
|
||||||
|
# ref makes git set the new branch's upstream to origin/main, so a later bare
|
||||||
|
# `git push` fails with a name-mismatch and suggests the dangerous
|
||||||
|
# `git push origin HEAD:main`. With no upstream, the first `git push -u origin
|
||||||
|
# <branch>` sets the correct one.
|
||||||
|
git worktree add --quiet --no-track -b "$branch" "$target" origin/main \
|
||||||
|
|| die "git worktree add failed"
|
||||||
|
|
||||||
|
log "ready — cd into it:"
|
||||||
|
echo "$target"
|
||||||
Executable
+68
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# wt-rm.sh — remove the worktree for a named branch.
|
||||||
|
#
|
||||||
|
# EXPLICIT BY DESIGN: it removes ONLY the branch you name. There is no
|
||||||
|
# scanning and no "is it merged into main?" heuristic to decide *what* to
|
||||||
|
# remove — long-lived branches (e.g. `website`, the `ci` line) are merged
|
||||||
|
# into main too, so auto-detection would happily delete them. Naming the
|
||||||
|
# target is the safety.
|
||||||
|
#
|
||||||
|
# Safety still comes from git's own refusals, not from cleverness here:
|
||||||
|
# - the worktree won't be removed if it has uncommitted/untracked changes
|
||||||
|
# (no `--force`);
|
||||||
|
# - the local branch is deleted ONLY if it is fully merged into
|
||||||
|
# `origin/main` — otherwise the worktree goes but the branch (and its
|
||||||
|
# unmerged commits) is kept.
|
||||||
|
#
|
||||||
|
# USAGE
|
||||||
|
# scripts/wt-rm.sh <branch>
|
||||||
|
# scripts/wt-rm.sh feat/clause-concept-hints
|
||||||
|
#
|
||||||
|
# For a worktree whose directory you already deleted by hand, use the
|
||||||
|
# built-in `git worktree prune` (it only clears bookkeeping — never a
|
||||||
|
# directory or a branch).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
log() { printf ' wt-rm: %s\n' "$*" >&2; }
|
||||||
|
die() { printf 'wt-rm: ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ $# -eq 1 ] || die "usage: wt-rm.sh <branch>"
|
||||||
|
branch="$1"
|
||||||
|
|
||||||
|
git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repo"
|
||||||
|
|
||||||
|
# Resolve the named branch to its worktree path.
|
||||||
|
path=""; cur=""
|
||||||
|
while read -r key val; do
|
||||||
|
case "$key" in
|
||||||
|
worktree) cur="$val" ;;
|
||||||
|
branch) [ "${val#refs/heads/}" = "$branch" ] && path="$cur" ;;
|
||||||
|
esac
|
||||||
|
done < <(git worktree list --porcelain)
|
||||||
|
|
||||||
|
[ -n "$path" ] || die "no worktree is checked out on branch '$branch' (see: git worktree list)"
|
||||||
|
|
||||||
|
main_wt="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
|
||||||
|
here="$(git rev-parse --show-toplevel)"
|
||||||
|
[ "$path" != "$main_wt" ] || die "refusing to remove the primary checkout ($path)"
|
||||||
|
[ "$path" != "$here" ] || die "refusing to remove the worktree you're standing in — run this from elsewhere"
|
||||||
|
|
||||||
|
log "removing worktree $path (branch $branch)"
|
||||||
|
git worktree remove "$path" \
|
||||||
|
|| die "could not remove $path — it likely has uncommitted or untracked changes; clean it (or remove by hand) first"
|
||||||
|
|
||||||
|
# Delete the local branch only if it is safely merged into origin/main;
|
||||||
|
# otherwise keep it so unmerged commits are never lost. (No auto-fetch — it
|
||||||
|
# judges against your current origin/main; run `git fetch` first if you want
|
||||||
|
# a freshly-merged branch to be eligible.)
|
||||||
|
if git rev-parse --verify --quiet origin/main >/dev/null \
|
||||||
|
&& git merge-base --is-ancestor "$branch" origin/main; then
|
||||||
|
git branch -D "$branch" >/dev/null
|
||||||
|
log "deleted local branch $branch (merged into origin/main)"
|
||||||
|
else
|
||||||
|
log "kept local branch $branch — not merged into origin/main; delete with 'git branch -D $branch' if you're sure"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "done"
|
||||||
+661
-212
File diff suppressed because it is too large
Load Diff
+34
-34
@@ -30,9 +30,7 @@ use std::path::{Component, Path, PathBuf};
|
|||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
|
use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
|
||||||
|
|
||||||
use crate::project::{
|
use crate::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local};
|
||||||
HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// File names excluded from `export` zips. These are either
|
/// File names excluded from `export` zips. These are either
|
||||||
/// derived (`playground.db`), per-process (`.lock`),
|
/// derived (`playground.db`), per-process (`.lock`),
|
||||||
@@ -118,20 +116,14 @@ impl std::fmt::Display for ArchiveError {
|
|||||||
limit = format_args!("{limit:02}"),
|
limit = format_args!("{limit:02}"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
Self::InvalidZip(detail) => f.write_str(&crate::t!(
|
Self::InvalidZip(detail) => {
|
||||||
"archive.invalid_zip",
|
f.write_str(&crate::t!("archive.invalid_zip", detail = detail,))
|
||||||
detail = detail,
|
|
||||||
)),
|
|
||||||
Self::NotAProjectArchive => {
|
|
||||||
f.write_str(&crate::t!("archive.not_a_project_archive"))
|
|
||||||
}
|
}
|
||||||
Self::MultipleTopFolders => {
|
Self::NotAProjectArchive => f.write_str(&crate::t!("archive.not_a_project_archive")),
|
||||||
f.write_str(&crate::t!("archive.multiple_top_folders"))
|
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);
|
.unix_permissions(0o644);
|
||||||
|
|
||||||
add_directory_entry(&mut writer, project_name, dst_zip)?;
|
add_directory_entry(&mut writer, project_name, dst_zip)?;
|
||||||
add_directory_recursive(
|
add_directory_recursive(&mut writer, project_path, project_name, &options, dst_zip)?;
|
||||||
&mut writer,
|
|
||||||
project_path,
|
|
||||||
project_name,
|
|
||||||
&options,
|
|
||||||
dst_zip,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
writer.finish().map_err(|e| ArchiveError::Zip {
|
writer.finish().map_err(|e| ArchiveError::Zip {
|
||||||
path: dst_zip.to_path_buf(),
|
path: dst_zip.to_path_buf(),
|
||||||
@@ -392,10 +378,7 @@ pub struct ZipInspection {
|
|||||||
///
|
///
|
||||||
/// Returns the resolved target path and the suffix that was
|
/// Returns the resolved target path and the suffix that was
|
||||||
/// applied (0 if the original name was free, 2..=99 otherwise).
|
/// applied (0 if the original name was free, 2..=99 otherwise).
|
||||||
pub fn resolve_import_target(
|
pub fn resolve_import_target(parent: &Path, name: &str) -> Result<(PathBuf, u32), ArchiveError> {
|
||||||
parent: &Path,
|
|
||||||
name: &str,
|
|
||||||
) -> Result<(PathBuf, u32), ArchiveError> {
|
|
||||||
let direct = parent.join(name);
|
let direct = parent.join(name);
|
||||||
if !direct.exists() {
|
if !direct.exists() {
|
||||||
return Ok((direct, 0));
|
return Ok((direct, 0));
|
||||||
@@ -495,7 +478,9 @@ pub fn extract_into(
|
|||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
let mut buf = Vec::with_capacity(entry.size() as usize);
|
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(),
|
path: dst_path.clone(),
|
||||||
source,
|
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::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::create_dir_all(p.join("data")).unwrap();
|
||||||
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").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(PLAYGROUND_DB), [0u8; 32]).unwrap();
|
||||||
fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap();
|
fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap();
|
||||||
// Stray atomic-write staging file — must be excluded.
|
// Stray atomic-write staging file — must be excluded.
|
||||||
@@ -536,7 +525,9 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::write(
|
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],
|
[0u8; 16],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -618,11 +609,15 @@ mod tests {
|
|||||||
let zip_path = tmp.path().join("notaproject.zip");
|
let zip_path = tmp.path().join("notaproject.zip");
|
||||||
let f = fs::File::create(&zip_path).unwrap();
|
let f = fs::File::create(&zip_path).unwrap();
|
||||||
let mut w = ZipWriter::new(f);
|
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.write_all(b"hi").unwrap();
|
||||||
w.finish().unwrap();
|
w.finish().unwrap();
|
||||||
let err = inspect_zip(&zip_path).expect_err("must refuse");
|
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]
|
#[test]
|
||||||
@@ -631,13 +626,18 @@ mod tests {
|
|||||||
let zip_path = tmp.path().join("multi.zip");
|
let zip_path = tmp.path().join("multi.zip");
|
||||||
let f = fs::File::create(&zip_path).unwrap();
|
let f = fs::File::create(&zip_path).unwrap();
|
||||||
let mut w = ZipWriter::new(f);
|
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.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.write_all(b"x").unwrap();
|
||||||
w.finish().unwrap();
|
w.finish().unwrap();
|
||||||
let err = inspect_zip(&zip_path).expect_err("must refuse");
|
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]
|
#[test]
|
||||||
|
|||||||
+93
-24
@@ -30,6 +30,10 @@ pub struct Args {
|
|||||||
/// `--help` / `-h`: print usage to stdout and exit. The
|
/// `--help` / `-h`: print usage to stdout and exit. The
|
||||||
/// runtime checks this flag before doing any other work.
|
/// runtime checks this flag before doing any other work.
|
||||||
pub help: bool,
|
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
|
/// `--no-undo`: disable the auto-snapshot / undo machinery for
|
||||||
/// this run (ADR-0006 Amendment 1). When set, no snapshots are
|
/// this run (ADR-0006 Amendment 1). When set, no snapshots are
|
||||||
/// taken — zero per-command overhead — and `undo` / `redo`
|
/// taken — zero per-command overhead — and `undo` / `redo`
|
||||||
@@ -62,6 +66,17 @@ pub fn help_text() -> String {
|
|||||||
crate::t!("help.cli_banner")
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum ArgsError {
|
pub enum ArgsError {
|
||||||
MissingValue(&'static str),
|
MissingValue(&'static str),
|
||||||
@@ -81,10 +96,7 @@ pub enum ArgsError {
|
|||||||
impl std::fmt::Display for ArgsError {
|
impl std::fmt::Display for ArgsError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::MissingValue(flag) => f.write_str(&crate::t!(
|
Self::MissingValue(flag) => f.write_str(&crate::t!("cli.missing_value", flag = flag,)),
|
||||||
"cli.missing_value",
|
|
||||||
flag = flag,
|
|
||||||
)),
|
|
||||||
Self::InvalidValue {
|
Self::InvalidValue {
|
||||||
flag,
|
flag,
|
||||||
value,
|
value,
|
||||||
@@ -95,10 +107,7 @@ impl std::fmt::Display for ArgsError {
|
|||||||
value = value,
|
value = value,
|
||||||
expected = expected,
|
expected = expected,
|
||||||
)),
|
)),
|
||||||
Self::Unknown(arg) => f.write_str(&crate::t!(
|
Self::Unknown(arg) => f.write_str(&crate::t!("cli.unknown_argument", arg = arg,)),
|
||||||
"cli.unknown_argument",
|
|
||||||
arg = arg,
|
|
||||||
)),
|
|
||||||
Self::MultiplePaths { first, second } => f.write_str(&crate::t!(
|
Self::MultiplePaths { first, second } => f.write_str(&crate::t!(
|
||||||
"cli.multiple_paths",
|
"cli.multiple_paths",
|
||||||
first = first,
|
first = first,
|
||||||
@@ -129,6 +138,7 @@ impl Args {
|
|||||||
let mut project_path: Option<PathBuf> = None;
|
let mut project_path: Option<PathBuf> = None;
|
||||||
let mut resume = false;
|
let mut resume = false;
|
||||||
let mut help = false;
|
let mut help = false;
|
||||||
|
let mut version = false;
|
||||||
let mut no_undo = false;
|
let mut no_undo = false;
|
||||||
let mut mode: Option<Mode> = None;
|
let mut mode: Option<Mode> = None;
|
||||||
// Demonstration mode (ADR-0047): the env var is the default,
|
// Demonstration mode (ADR-0047): the env var is the default,
|
||||||
@@ -143,6 +153,9 @@ impl Args {
|
|||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
help = true;
|
help = true;
|
||||||
}
|
}
|
||||||
|
"--version" | "-V" => {
|
||||||
|
version = true;
|
||||||
|
}
|
||||||
"--resume" => {
|
"--resume" => {
|
||||||
resume = true;
|
resume = true;
|
||||||
}
|
}
|
||||||
@@ -208,6 +221,7 @@ impl Args {
|
|||||||
project_path,
|
project_path,
|
||||||
resume,
|
resume,
|
||||||
help,
|
help,
|
||||||
|
version,
|
||||||
no_undo,
|
no_undo,
|
||||||
mode,
|
mode,
|
||||||
demo,
|
demo,
|
||||||
@@ -241,7 +255,11 @@ fn default_theme() -> Theme {
|
|||||||
// Standard convention: 0..=6 and 8 are dark backgrounds,
|
// Standard convention: 0..=6 and 8 are dark backgrounds,
|
||||||
// 7 and 9..=15 are light. ITerm emits 15 for white-ish.
|
// 7 and 9..=15 are light. ITerm emits 15 for white-ish.
|
||||||
let is_dark = matches!(code, 0..=6 | 8);
|
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()
|
Theme::default()
|
||||||
}
|
}
|
||||||
@@ -294,10 +312,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mode_flag_simple_and_advanced() {
|
fn mode_flag_simple_and_advanced() {
|
||||||
assert_eq!(Args::parse(["--mode", "simple"]).unwrap().mode, Some(Mode::Simple));
|
assert_eq!(
|
||||||
assert_eq!(Args::parse(["--mode", "advanced"]).unwrap().mode, Some(Mode::Advanced));
|
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.
|
// 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]
|
#[test]
|
||||||
@@ -330,7 +357,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn data_dir_flag_parses() {
|
fn data_dir_flag_parses() {
|
||||||
let args = Args::parse(["--data-dir", "/tmp/playground-data"]).unwrap();
|
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]
|
#[test]
|
||||||
@@ -350,13 +380,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn data_dir_and_positional_can_coexist() {
|
fn data_dir_and_positional_can_coexist() {
|
||||||
let args = Args::parse([
|
let args = Args::parse(["--data-dir", "/tmp/data", "/home/me/MyProject"]).unwrap();
|
||||||
"--data-dir",
|
assert_eq!(
|
||||||
"/tmp/data",
|
args.data_dir.as_deref(),
|
||||||
"/home/me/MyProject",
|
Some(std::path::Path::new("/tmp/data"))
|
||||||
])
|
);
|
||||||
.unwrap();
|
|
||||||
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/data")));
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
args.project_path.as_deref(),
|
args.project_path.as_deref(),
|
||||||
Some(std::path::Path::new("/home/me/MyProject"))
|
Some(std::path::Path::new("/home/me/MyProject"))
|
||||||
@@ -366,7 +394,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn two_positional_paths_error() {
|
fn two_positional_paths_error() {
|
||||||
let err = Args::parse(["/a", "/b"]).unwrap_err();
|
let err = Args::parse(["/a", "/b"]).unwrap_err();
|
||||||
assert!(matches!(err, ArgsError::MultiplePaths { .. }), "got: {err:?}");
|
assert!(
|
||||||
|
matches!(err, ArgsError::MultiplePaths { .. }),
|
||||||
|
"got: {err:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -435,7 +466,10 @@ mod tests {
|
|||||||
// Absent `--demo` (and absent env var in the test runner),
|
// Absent `--demo` (and absent env var in the test runner),
|
||||||
// demo mode is off — zero footprint for real users.
|
// demo mode is off — zero footprint for real users.
|
||||||
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
|
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]
|
#[test]
|
||||||
@@ -464,7 +498,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
// Disabling values.
|
// Disabling values.
|
||||||
for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] {
|
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:
|
// Make sure the path-vs-flag distinction is robust:
|
||||||
// unknown flags don't get silently swallowed as paths.
|
// unknown flags don't get silently swallowed as paths.
|
||||||
let err = Args::parse(["--bogus", "/some/path"]).unwrap_err();
|
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:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+204
-161
@@ -15,10 +15,10 @@
|
|||||||
//! `app.rs`; this module owns the candidate computation.
|
//! `app.rs`; this module owns the candidate computation.
|
||||||
|
|
||||||
use crate::dsl::grammar::IdentSource;
|
use crate::dsl::grammar::IdentSource;
|
||||||
|
use crate::dsl::parser::parse_command_with_schema_in_mode;
|
||||||
use crate::dsl::types::Type;
|
use crate::dsl::types::Type;
|
||||||
use crate::dsl::walker::outcome::Expectation;
|
use crate::dsl::walker::outcome::Expectation;
|
||||||
use crate::dsl::{ParseError, parse_command};
|
use crate::dsl::{ParseError, parse_command};
|
||||||
use crate::dsl::parser::parse_command_with_schema_in_mode;
|
|
||||||
use crate::mode::Mode;
|
use crate::mode::Mode;
|
||||||
|
|
||||||
/// Composite literal candidates whose lexed shape is more than
|
/// Composite literal candidates whose lexed shape is more than
|
||||||
@@ -275,11 +275,7 @@ pub struct Completion {
|
|||||||
/// (case-insensitive starts-with), combined, sorted, and
|
/// (case-insensitive starts-with), combined, sorted, and
|
||||||
/// deduplicated.
|
/// deduplicated.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn candidates_at_cursor(
|
pub fn candidates_at_cursor(input: &str, cursor: usize, cache: &SchemaCache) -> Option<Completion> {
|
||||||
input: &str,
|
|
||||||
cursor: usize,
|
|
||||||
cache: &SchemaCache,
|
|
||||||
) -> Option<Completion> {
|
|
||||||
candidates_at_cursor_in_mode(input, cursor, cache, Mode::Advanced)
|
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();
|
let word_boundary = run == 0 || bytes[run - 1].is_ascii_whitespace();
|
||||||
if run < cursor && bytes[run] == b'-' && word_boundary && run < start {
|
if run < cursor && bytes[run] == b'-' && word_boundary && run < start {
|
||||||
let pre = crate::dsl::walker::completion_probe_in_mode(&input[..run], cache, mode);
|
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;
|
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
|
// walk's `current_table_columns`; fall back to "the union of
|
||||||
// the look-ahead from_scope's bindings' columns" when leading
|
// the look-ahead from_scope's bindings' columns" when leading
|
||||||
// produced no in-scope columns. Phase-1 DSL paths unaffected.
|
// produced no in-scope columns. Phase-1 DSL paths unaffected.
|
||||||
let lookahead_union_columns: Vec<TableColumn> =
|
let lookahead_union_columns: Vec<TableColumn> = if probe.current_table_columns.is_none() {
|
||||||
if probe.current_table_columns.is_none() {
|
|
||||||
let mut out: Vec<TableColumn> = Vec::new();
|
let mut out: Vec<TableColumn> = Vec::new();
|
||||||
for binding in resolution_from_scope {
|
for binding in resolution_from_scope {
|
||||||
for col in &binding.columns {
|
for col in &binding.columns {
|
||||||
if !out.iter().any(|c| {
|
if !out.iter().any(|c| c.name.eq_ignore_ascii_case(&col.name)) {
|
||||||
c.name.eq_ignore_ascii_case(&col.name)
|
|
||||||
}) {
|
|
||||||
out.push(col.clone());
|
out.push(col.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -507,9 +504,7 @@ pub fn candidates_at_cursor_with_in_mode(
|
|||||||
// column list (the structural error path surfaces the
|
// column list (the structural error path surfaces the
|
||||||
// unresolved-prefix message).
|
// unresolved-prefix message).
|
||||||
let prefix_qualifier = peek_back_qualifier(input, start);
|
let prefix_qualifier = peek_back_qualifier(input, start);
|
||||||
let qualified_columns: Option<Vec<String>> = prefix_qualifier
|
let qualified_columns: Option<Vec<String>> = prefix_qualifier.as_ref().map(|q| {
|
||||||
.as_ref()
|
|
||||||
.map(|q| {
|
|
||||||
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON
|
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON
|
||||||
// CONFLICT … DO UPDATE` completes to the target table's
|
// CONFLICT … DO UPDATE` completes to the target table's
|
||||||
// columns — `excluded` mirrors the would-be-inserted row.
|
// 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()
|
cols.iter().map(|c| c.name.clone()).collect()
|
||||||
} else {
|
} else {
|
||||||
resolve_qualifier_columns_in(
|
resolve_qualifier_columns_in(q, resolution_from_scope, resolution_cte_bindings, cache)
|
||||||
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(_))
|
Some(crate::dsl::grammar::HintMode::ProseOnly(_))
|
||||||
);
|
);
|
||||||
if partial_prefix.is_empty()
|
if partial_prefix.is_empty()
|
||||||
&& (prose_only_slot
|
&& (prose_only_slot || (is_value_literal_signature(&expected) && !has_schema_ident))
|
||||||
|| (is_value_literal_signature(&expected) && !has_schema_ident))
|
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -642,11 +631,17 @@ pub fn candidates_at_cursor_with_in_mode(
|
|||||||
// Source 1.5: type-name candidates when the walker expects
|
// Source 1.5: type-name candidates when the walker expects
|
||||||
// a column-type slot. Type names are a closed set sourced
|
// a column-type slot. Type names are a closed set sourced
|
||||||
// from `Type::all()` (ADR-0005 declaration order:
|
// from `Type::all()` (ADR-0005 declaration order:
|
||||||
// text/int/real/decimal/bool/date/datetime/blob/serial/
|
// text/int/real/decimal/bool/date/datetime/serial/
|
||||||
// shortid). The walker surfaces this as
|
// shortid). The walker surfaces this as
|
||||||
// `Expectation::Ident { source: Types }`.
|
// `Expectation::Ident { source: Types }`.
|
||||||
let type_names: Vec<String> = if expected.iter().any(|e| {
|
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()
|
Type::all()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -725,7 +720,13 @@ pub fn candidates_at_cursor_with_in_mode(
|
|||||||
// filtered like every other source; empty prefix offers the whole
|
// filtered like every other source; empty prefix offers the whole
|
||||||
// set. Tagged `CandidateKind::Function` for its own colour.
|
// set. Tagged `CandidateKind::Function` for its own colour.
|
||||||
let has_sql_expr_slot = expected.iter().any(|e| {
|
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 {
|
let mut functions: Vec<String> = if has_sql_expr_slot {
|
||||||
crate::dsl::sql_functions::KNOWN_SQL_FUNCTIONS
|
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` /
|
// curated vocabulary is offered so a learner can discover `email` /
|
||||||
// `product` / … by Tab. Same `Function` kind / `tok_function` colour
|
// `product` / … by Tab. Same `Function` kind / `tok_function` colour
|
||||||
// as SQL functions (no new theme colour — ADR-0048 §Grammar).
|
// as SQL functions (no new theme colour — ADR-0048 §Grammar).
|
||||||
let has_generator_slot = expected
|
let has_generator_slot = expected.iter().any(|e| {
|
||||||
.iter()
|
matches!(
|
||||||
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. }));
|
e,
|
||||||
|
Expectation::Ident {
|
||||||
|
source: IdentSource::Generators,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
});
|
||||||
if has_generator_slot {
|
if has_generator_slot {
|
||||||
functions.extend(
|
functions.extend(
|
||||||
crate::seed::KNOWN_GENERATORS
|
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
|
// (the `typing_over_diag` path) — keeps the alias from flashing as
|
||||||
// a bogus "unknown column" while typing. Mixed into `identifiers`
|
// a bogus "unknown column" while typing. Mixed into `identifiers`
|
||||||
// so it sorts/dedups/colours uniformly with column candidates.
|
// so it sorts/dedups/colours uniformly with column candidates.
|
||||||
let alias_candidates: Vec<String> =
|
let alias_candidates: Vec<String> = if has_sql_expr_slot && prefix_qualifier.is_none() {
|
||||||
if has_sql_expr_slot && prefix_qualifier.is_none() {
|
|
||||||
// Once the partial *exactly* matches an in-scope qualifier,
|
// Once the partial *exactly* matches an in-scope qualifier,
|
||||||
// discoverability is served — the learner has a whole alias
|
// discoverability is served — the learner has a whole alias
|
||||||
// in hand and now needs the "add `.column`" hint
|
// in hand and now needs the "add `.column`" hint
|
||||||
@@ -784,8 +790,7 @@ pub fn candidates_at_cursor_with_in_mode(
|
|||||||
} else {
|
} else {
|
||||||
let mut out: Vec<String> = Vec::new();
|
let mut out: Vec<String> = Vec::new();
|
||||||
for binding in resolution_from_scope {
|
for binding in resolution_from_scope {
|
||||||
let qualifier =
|
let qualifier = binding.alias.as_deref().unwrap_or(binding.table.as_str());
|
||||||
binding.alias.as_deref().unwrap_or(binding.table.as_str());
|
|
||||||
if matches_prefix(qualifier)
|
if matches_prefix(qualifier)
|
||||||
&& !out.iter().any(|q| q.eq_ignore_ascii_case(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
|
let mut identifiers: Vec<String> = expected
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| match e {
|
.filter_map(|e| match e {
|
||||||
Expectation::Ident { source, .. } if source.completes_from_schema() => {
|
Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
|
||||||
Some(*source)
|
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.flat_map(|source| {
|
.flat_map(|source| {
|
||||||
@@ -1007,11 +1010,7 @@ fn resolve_qualifier_columns_in(
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
|
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
|
||||||
{
|
{
|
||||||
return cte
|
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
|
||||||
.columns
|
|
||||||
.iter()
|
|
||||||
.filter_map(|c| c.name.clone())
|
|
||||||
.collect();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Second: table-name match in the active from_scope.
|
// Second: table-name match in the active from_scope.
|
||||||
@@ -1026,11 +1025,7 @@ fn resolve_qualifier_columns_in(
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
|
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
|
||||||
{
|
{
|
||||||
return cte
|
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
|
||||||
.columns
|
|
||||||
.iter()
|
|
||||||
.filter_map(|c| c.name.clone())
|
|
||||||
.collect();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Third: direct cte_bindings match (cte_alias.|).
|
// Third: direct cte_bindings match (cte_alias.|).
|
||||||
@@ -1038,11 +1033,7 @@ fn resolve_qualifier_columns_in(
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|c| c.name.eq_ignore_ascii_case(qualifier))
|
.find(|c| c.name.eq_ignore_ascii_case(qualifier))
|
||||||
{
|
{
|
||||||
return cte
|
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
|
||||||
.columns
|
|
||||||
.iter()
|
|
||||||
.filter_map(|c| c.name.clone())
|
|
||||||
.collect();
|
|
||||||
}
|
}
|
||||||
// Fourth: a bare table name from the schema cache — DSL
|
// Fourth: a bare table name from the schema cache — DSL
|
||||||
// paths reach this for `from <Table>.<col>` shapes where
|
// 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
|
// column. So `select Agx` warns at typing time again, while
|
||||||
// `select sum` does not.
|
// `select sum` does not.
|
||||||
let has_sql_expr_slot = expected.iter().any(|e| {
|
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) {
|
if has_sql_expr_slot && crate::dsl::sql_functions::is_known_function_prefix(partial) {
|
||||||
return None;
|
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
|
// schema-column check below would never see it. A partial that
|
||||||
// prefix-matches a known generator is an in-progress name; anything
|
// prefix-matches a known generator is an in-progress name; anything
|
||||||
// else is an unknown generator → flag it `[ERR]` while typing.
|
// else is an unknown generator → flag it `[ERR]` while typing.
|
||||||
let has_generator_slot = expected
|
let has_generator_slot = expected.iter().any(|e| {
|
||||||
.iter()
|
matches!(
|
||||||
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. }));
|
e,
|
||||||
|
Expectation::Ident {
|
||||||
|
source: IdentSource::Generators,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
});
|
||||||
if has_generator_slot {
|
if has_generator_slot {
|
||||||
if crate::seed::is_known_generator_prefix(partial) {
|
if crate::seed::is_known_generator_prefix(partial) {
|
||||||
return None;
|
return None;
|
||||||
@@ -1335,9 +1338,7 @@ pub fn invalid_ident_at_cursor_in_mode(
|
|||||||
let sources: Vec<IdentSource> = expected
|
let sources: Vec<IdentSource> = expected
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| match e {
|
.filter_map(|e| match e {
|
||||||
Expectation::Ident { source, .. } if source.completes_from_schema() => {
|
Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
|
||||||
Some(*source)
|
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1412,13 +1413,15 @@ mod tests {
|
|||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
fn cands(input: &str, cursor: usize) -> Vec<String> {
|
fn cands(input: &str, cursor: usize) -> Vec<String> {
|
||||||
candidates_at_cursor(input, cursor, &SchemaCache::default())
|
candidates_at_cursor(input, cursor, &SchemaCache::default()).map_or_else(Vec::new, |c| {
|
||||||
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
|
c.candidates.into_iter().map(|c| c.text).collect()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec<String> {
|
fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec<String> {
|
||||||
candidates_at_cursor(input, cursor, cache)
|
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
|
||||||
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
|
c.candidates.into_iter().map(|c| c.text).collect()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple-mode completion candidates — the DSL surface
|
/// Simple-mode completion candidates — the DSL surface
|
||||||
@@ -1429,7 +1432,9 @@ mod tests {
|
|||||||
/// Advanced mode surfaces the SQL grammar's completions instead.
|
/// Advanced mode surfaces the SQL grammar's completions instead.
|
||||||
fn cands_simple(input: &str, cursor: usize) -> Vec<String> {
|
fn cands_simple(input: &str, cursor: usize) -> Vec<String> {
|
||||||
candidates_at_cursor_in_mode(input, cursor, &SchemaCache::default(), Mode::Simple)
|
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(
|
fn cand_kinds_with(
|
||||||
@@ -1438,10 +1443,7 @@ mod tests {
|
|||||||
cache: &SchemaCache,
|
cache: &SchemaCache,
|
||||||
) -> Vec<(String, CandidateKind)> {
|
) -> Vec<(String, CandidateKind)> {
|
||||||
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
|
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
|
||||||
c.candidates
|
c.candidates.into_iter().map(|c| (c.text, c.kind)).collect()
|
||||||
.into_iter()
|
|
||||||
.map(|c| (c.text, c.kind))
|
|
||||||
.collect()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1503,12 +1505,21 @@ mod tests {
|
|||||||
// Simple-only (column, relationship, constraint).
|
// Simple-only (column, relationship, constraint).
|
||||||
let cs = cands("drop ", 5);
|
let cs = cands("drop ", 5);
|
||||||
for kw in ["table", "index", "column", "relationship", "constraint"] {
|
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.
|
// Both-mode continuations block before the simple-only ones.
|
||||||
let pos = |k: &str| cs.iter().position(|c| c == k).unwrap();
|
let pos = |k: &str| cs.iter().position(|c| c == k).unwrap();
|
||||||
assert!(pos("table") < pos("column"), "Both block precedes Simple block: {cs:?}");
|
assert!(
|
||||||
assert!(pos("index") < pos("relationship"), "Both block precedes Simple block: {cs:?}");
|
pos("table") < pos("column"),
|
||||||
|
"Both block precedes Simple block: {cs:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
pos("index") < pos("relationship"),
|
||||||
|
"Both block precedes Simple block: {cs:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1631,8 +1642,14 @@ mod tests {
|
|||||||
let c = candidates_at_cursor(input, input.len(), &SchemaCache::default())
|
let c = candidates_at_cursor(input, input.len(), &SchemaCache::default())
|
||||||
.expect("a `-` at a flag position offers candidates");
|
.expect("a `-` at a flag position offers candidates");
|
||||||
let texts: Vec<&str> = c.candidates.iter().map(|x| x.text.as_str()).collect();
|
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!(
|
||||||
assert!(!texts.contains(&"on"), "must NOT offer `on` after a dash: {texts:?}");
|
texts.contains(&"--create-fk"),
|
||||||
|
"should offer --create-fk: {texts:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!texts.contains(&"on"),
|
||||||
|
"must NOT offer `on` after a dash: {texts:?}"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.replaced_range,
|
c.replaced_range,
|
||||||
(input.len() - 1, input.len()),
|
(input.len() - 1, input.len()),
|
||||||
@@ -1643,12 +1660,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn double_dash_replaces_both_dashes_on_accept() {
|
fn double_dash_replaces_both_dashes_on_accept() {
|
||||||
let input = "delete from T --";
|
let input = "delete from T --";
|
||||||
let c = candidates_at_cursor_in_mode(
|
let c =
|
||||||
input,
|
candidates_at_cursor_in_mode(input, input.len(), &SchemaCache::default(), Mode::Simple)
|
||||||
input.len(),
|
|
||||||
&SchemaCache::default(),
|
|
||||||
Mode::Simple,
|
|
||||||
)
|
|
||||||
.expect("`--` offers the flag");
|
.expect("`--` offers the flag");
|
||||||
assert!(c.candidates.iter().any(|x| x.text == "--all-rows"));
|
assert!(c.candidates.iter().any(|x| x.text == "--all-rows"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1668,9 +1681,7 @@ mod tests {
|
|||||||
s.tables.push("T".into());
|
s.tables.push("T".into());
|
||||||
s.columns.push("x".into());
|
s.columns.push("x".into());
|
||||||
let input = "show data T where x = -5";
|
let input = "show data T where x = -5";
|
||||||
if let Some(c) =
|
if let Some(c) = candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple) {
|
||||||
candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple)
|
|
||||||
{
|
|
||||||
assert!(
|
assert!(
|
||||||
!c.candidates.iter().any(|x| x.text.starts_with("--")),
|
!c.candidates.iter().any(|x| x.text.starts_with("--")),
|
||||||
"no flags at a value position: {:?}",
|
"no flags at a value position: {:?}",
|
||||||
@@ -1715,8 +1726,8 @@ mod tests {
|
|||||||
// App-lifecycle commands now appear alongside DSL
|
// App-lifecycle commands now appear alongside DSL
|
||||||
// commands in the entry-keyword set.
|
// commands in the entry-keyword set.
|
||||||
for expected in &[
|
for expected in &[
|
||||||
"quit", "help", "rebuild", "save", "new", "load", "export",
|
"quit", "help", "rebuild", "save", "new", "load", "export", "import", "mode",
|
||||||
"import", "mode", "messages", "undo", "redo", "copy",
|
"messages", "undo", "redo", "copy",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
cs.contains(&expected.to_string()),
|
cs.contains(&expected.to_string()),
|
||||||
@@ -1943,7 +1954,10 @@ mod tests {
|
|||||||
// opening a sub-shape) becomes a Tab candidate.
|
// opening a sub-shape) becomes a Tab candidate.
|
||||||
let input = "add column to table T";
|
let input = "add column to table T";
|
||||||
let cs = cands(input, input.len());
|
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]
|
#[test]
|
||||||
@@ -1957,10 +1971,7 @@ mod tests {
|
|||||||
assert!(cs.contains(&"(".to_string()), "got {cs:?}");
|
assert!(cs.contains(&"(".to_string()), "got {cs:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn schema_with_table(
|
fn schema_with_table(table: &str, columns: &[(&str, crate::dsl::types::Type)]) -> SchemaCache {
|
||||||
table: &str,
|
|
||||||
columns: &[(&str, crate::dsl::types::Type)],
|
|
||||||
) -> SchemaCache {
|
|
||||||
let mut cache = SchemaCache::default();
|
let mut cache = SchemaCache::default();
|
||||||
cache.tables.push(table.to_string());
|
cache.tables.push(table.to_string());
|
||||||
let cols: Vec<TableColumn> = columns
|
let cols: Vec<TableColumn> = columns
|
||||||
@@ -2002,8 +2013,14 @@ mod tests {
|
|||||||
let cache = two_table_alias_cache();
|
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 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);
|
let cs = cands_with(input, input.len(), &cache);
|
||||||
assert!(cs.contains(&"o".to_string()), "alias `o` must be offered; got {cs:?}");
|
assert!(
|
||||||
assert!(cs.contains(&"z".to_string()), "alias `z` must be offered; got {cs:?}");
|
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]
|
#[test]
|
||||||
@@ -2015,8 +2032,14 @@ mod tests {
|
|||||||
let cache = two_table_alias_cache();
|
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 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);
|
let cs = cands_with(input, input.len(), &cache);
|
||||||
assert!(cs.contains(&"aa".to_string()), "alias `aa` must be offered; got {cs:?}");
|
assert!(
|
||||||
assert!(cs.contains(&"ab".to_string()), "alias `ab` must be offered; got {cs:?}");
|
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.
|
// 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";
|
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
|
// SchemaCache.columns has columns from many tables, but
|
||||||
// at `update Customers set ` only Customers' columns
|
// at `update Customers set ` only Customers' columns
|
||||||
// should appear.
|
// should appear.
|
||||||
let mut cache = schema_with_table(
|
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
|
||||||
"Customers",
|
|
||||||
&[("id", Type::Int), ("Email", Type::Text)],
|
|
||||||
);
|
|
||||||
// Pretend the global flat list has columns from a second
|
// Pretend the global flat list has columns from a second
|
||||||
// table that aren't in Customers.
|
// table that aren't in Customers.
|
||||||
cache.columns.push("OrderTotal".to_string());
|
cache.columns.push("OrderTotal".to_string());
|
||||||
cache.columns.push("Stock".to_string());
|
cache.columns.push("Stock".to_string());
|
||||||
cache
|
cache.table_columns.insert(
|
||||||
.table_columns
|
"Orders".to_string(),
|
||||||
.insert("Orders".to_string(), vec![
|
vec![TableColumn {
|
||||||
TableColumn { name: "OrderTotal".to_string(), user_type: Type::Real, not_null: false, has_default: false },
|
name: "OrderTotal".to_string(),
|
||||||
]);
|
user_type: Type::Real,
|
||||||
|
not_null: false,
|
||||||
|
has_default: false,
|
||||||
|
}],
|
||||||
|
);
|
||||||
cache.tables.push("Orders".to_string());
|
cache.tables.push("Orders".to_string());
|
||||||
let cs = cands_with("update Customers set ", 21, &cache);
|
let cs = cands_with("update Customers set ", 21, &cache);
|
||||||
// Customers's columns should appear:
|
// Customers's columns should appear:
|
||||||
@@ -2079,10 +2103,7 @@ mod tests {
|
|||||||
// *before* ORDER BY (the FROM's JOIN options, WHERE /
|
// *before* ORDER BY (the FROM's JOIN options, WHERE /
|
||||||
// GROUP BY / HAVING, set-ops). Those used to shove the
|
// GROUP BY / HAVING, set-ops). Those used to shove the
|
||||||
// columns off-screen.
|
// columns off-screen.
|
||||||
let cache = schema_with_table(
|
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
|
||||||
"Things",
|
|
||||||
&[("Name", Type::Text), ("Qty", Type::Int)],
|
|
||||||
);
|
|
||||||
let input = "select Name from Things order by ";
|
let input = "select Name from Things order by ";
|
||||||
let cs = cands_with(input, input.len(), &cache);
|
let cs = cands_with(input, input.len(), &cache);
|
||||||
// The columns the user wants are offered:
|
// The columns the user wants are offered:
|
||||||
@@ -2090,8 +2111,19 @@ mod tests {
|
|||||||
assert!(cs.contains(&"Qty".to_string()), "got {cs:?}");
|
assert!(cs.contains(&"Qty".to_string()), "got {cs:?}");
|
||||||
// Preceding-clause keywords must not leak in:
|
// Preceding-clause keywords must not leak in:
|
||||||
for kw in [
|
for kw in [
|
||||||
"where", "group", "having", "join", "union", "intersect",
|
"where",
|
||||||
"except", "left", "right", "full", "cross", "inner", "as",
|
"group",
|
||||||
|
"having",
|
||||||
|
"join",
|
||||||
|
"union",
|
||||||
|
"intersect",
|
||||||
|
"except",
|
||||||
|
"left",
|
||||||
|
"right",
|
||||||
|
"full",
|
||||||
|
"cross",
|
||||||
|
"inner",
|
||||||
|
"as",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
!cs.contains(&kw.to_string()),
|
!cs.contains(&kw.to_string()),
|
||||||
@@ -2108,10 +2140,7 @@ mod tests {
|
|||||||
// sort item the direction keywords surface as
|
// sort item the direction keywords surface as
|
||||||
// continuations (previously discarded at the Repeated
|
// continuations (previously discarded at the Repeated
|
||||||
// boundary, so completion offered neither).
|
// boundary, so completion offered neither).
|
||||||
let cache = schema_with_table(
|
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
|
||||||
"Things",
|
|
||||||
&[("Name", Type::Text), ("Qty", Type::Int)],
|
|
||||||
);
|
|
||||||
let input = "select Name from Things order by Name ";
|
let input = "select Name from Things order by Name ";
|
||||||
let cs = cands_with(input, input.len(), &cache);
|
let cs = cands_with(input, input.len(), &cache);
|
||||||
assert!(cs.contains(&"asc".to_string()), "got {cs:?}");
|
assert!(cs.contains(&"asc".to_string()), "got {cs:?}");
|
||||||
@@ -2123,10 +2152,7 @@ mod tests {
|
|||||||
use crate::dsl::types::Type;
|
use crate::dsl::types::Type;
|
||||||
// walk_repeated trailing-optional fix: after a complete
|
// walk_repeated trailing-optional fix: after a complete
|
||||||
// projection item the `as` alias keyword surfaces.
|
// projection item the `as` alias keyword surfaces.
|
||||||
let cache = schema_with_table(
|
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
|
||||||
"Things",
|
|
||||||
&[("Name", Type::Text), ("Qty", Type::Int)],
|
|
||||||
);
|
|
||||||
let input = "select Name ";
|
let input = "select Name ";
|
||||||
let cs = cands_with(input, input.len(), &cache);
|
let cs = cands_with(input, input.len(), &cache);
|
||||||
assert!(cs.contains(&"as".to_string()), "got {cs:?}");
|
assert!(cs.contains(&"as".to_string()), "got {cs:?}");
|
||||||
@@ -2153,16 +2179,13 @@ mod tests {
|
|||||||
// ADR-0022 Amendment 2: at an expression position offering
|
// ADR-0022 Amendment 2: at an expression position offering
|
||||||
// both column names and keywords, every column precedes
|
// both column names and keywords, every column precedes
|
||||||
// every keyword so the names stay visible by default.
|
// every keyword so the names stay visible by default.
|
||||||
let cache = schema_with_table(
|
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
|
||||||
"Things",
|
|
||||||
&[("Name", Type::Text), ("Qty", Type::Int)],
|
|
||||||
);
|
|
||||||
let input = "select * from Things where ";
|
let input = "select * from Things where ";
|
||||||
let cs = cands_with(input, input.len(), &cache);
|
let cs = cands_with(input, input.len(), &cache);
|
||||||
let pos = |needle: &str| {
|
let pos = |needle: &str| {
|
||||||
cs.iter().position(|c| c == needle).unwrap_or_else(|| {
|
cs.iter()
|
||||||
panic!("{needle:?} not in candidates: {cs:?}")
|
.position(|c| c == needle)
|
||||||
})
|
.unwrap_or_else(|| panic!("{needle:?} not in candidates: {cs:?}"))
|
||||||
};
|
};
|
||||||
// Both columns come before any expression-start keyword.
|
// Both columns come before any expression-start keyword.
|
||||||
let last_ident = pos("Name").max(pos("Qty"));
|
let last_ident = pos("Name").max(pos("Qty"));
|
||||||
@@ -2176,13 +2199,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn update_where_offers_only_current_table_columns() {
|
fn update_where_offers_only_current_table_columns() {
|
||||||
use crate::dsl::types::Type;
|
use crate::dsl::types::Type;
|
||||||
let mut cache = schema_with_table(
|
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
|
||||||
"Customers",
|
|
||||||
&[("id", Type::Int), ("Email", Type::Text)],
|
|
||||||
);
|
|
||||||
cache.columns.push("OrderTotal".to_string());
|
cache.columns.push("OrderTotal".to_string());
|
||||||
let cs =
|
let cs = cands_with("update Customers set Email='x' where ", 37, &cache);
|
||||||
cands_with("update Customers set Email='x' where ", 37, &cache);
|
|
||||||
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
|
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
|
||||||
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
|
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
|
||||||
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
|
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
|
||||||
@@ -2208,7 +2227,11 @@ mod tests {
|
|||||||
use crate::dsl::types::Type;
|
use crate::dsl::types::Type;
|
||||||
let cache = schema_with_table(
|
let cache = schema_with_table(
|
||||||
"Customers",
|
"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);
|
let cs = cands_with("insert into Customers (", 23, &cache);
|
||||||
// The user is at Form A's column-list position. All
|
// The user is at Form A's column-list position. All
|
||||||
@@ -2222,10 +2245,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn insert_into_open_paren_does_not_offer_unrelated_columns() {
|
fn insert_into_open_paren_does_not_offer_unrelated_columns() {
|
||||||
use crate::dsl::types::Type;
|
use crate::dsl::types::Type;
|
||||||
let mut cache = schema_with_table(
|
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
|
||||||
"Customers",
|
|
||||||
&[("id", Type::Int), ("Email", Type::Text)],
|
|
||||||
);
|
|
||||||
cache.columns.push("OrderTotal".to_string());
|
cache.columns.push("OrderTotal".to_string());
|
||||||
let cs = cands_with("insert into Customers (", 23, &cache);
|
let cs = cands_with("insert into Customers (", 23, &cache);
|
||||||
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
|
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
|
||||||
@@ -2239,13 +2259,9 @@ mod tests {
|
|||||||
// table's columns. `OrderTotal` belongs to no table in
|
// table's columns. `OrderTotal` belongs to no table in
|
||||||
// this cache's `table_columns`, so it must not leak.
|
// this cache's `table_columns`, so it must not leak.
|
||||||
use crate::dsl::types::Type;
|
use crate::dsl::types::Type;
|
||||||
let mut cache = schema_with_table(
|
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
|
||||||
"Customers",
|
|
||||||
&[("id", Type::Int), ("Email", Type::Text)],
|
|
||||||
);
|
|
||||||
cache.columns.push("OrderTotal".to_string());
|
cache.columns.push("OrderTotal".to_string());
|
||||||
let cs =
|
let cs = cands_with("drop column from Customers: ", 28, &cache);
|
||||||
cands_with("drop column from Customers: ", 28, &cache);
|
|
||||||
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
|
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
|
||||||
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
|
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2271,8 +2287,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cursor_mid_keyword_replaces_only_the_partial_prefix() {
|
fn cursor_mid_keyword_replaces_only_the_partial_prefix() {
|
||||||
let comp = candidates_at_cursor("cre", 3, &SchemaCache::default())
|
let comp =
|
||||||
.expect("some completion");
|
candidates_at_cursor("cre", 3, &SchemaCache::default()).expect("some completion");
|
||||||
assert_eq!(comp.replaced_range, (0, 3));
|
assert_eq!(comp.replaced_range, (0, 3));
|
||||||
assert_eq!(comp.partial_prefix, "cre");
|
assert_eq!(comp.partial_prefix, "cre");
|
||||||
assert_eq!(comp.candidates.len(), 1);
|
assert_eq!(comp.candidates.len(), 1);
|
||||||
@@ -2282,8 +2298,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cursor_at_word_boundary_has_empty_partial_prefix() {
|
fn cursor_at_word_boundary_has_empty_partial_prefix() {
|
||||||
let comp = candidates_at_cursor("create ", 7, &SchemaCache::default())
|
let comp =
|
||||||
.expect("some completion");
|
candidates_at_cursor("create ", 7, &SchemaCache::default()).expect("some completion");
|
||||||
assert_eq!(comp.replaced_range, (7, 7));
|
assert_eq!(comp.replaced_range, (7, 7));
|
||||||
assert_eq!(comp.partial_prefix, "");
|
assert_eq!(comp.partial_prefix, "");
|
||||||
}
|
}
|
||||||
@@ -2293,7 +2309,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn type_slot_offers_full_type_vocabulary_when_partial_empty() {
|
fn type_slot_offers_full_type_vocabulary_when_partial_empty() {
|
||||||
// After `add column to T: Name (` the parser expects
|
// After `add column to T: Name (` the parser expects
|
||||||
// a column type. With no partial typed, all ten types
|
// a column type. With no partial typed, all nine types
|
||||||
// from `Type::all()` are offered in declaration order.
|
// from `Type::all()` are offered in declaration order.
|
||||||
let cs = cands("add column to T: Name (", 23);
|
let cs = cands("add column to T: Name (", 23);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2306,7 +2322,6 @@ mod tests {
|
|||||||
"bool".to_string(),
|
"bool".to_string(),
|
||||||
"date".to_string(),
|
"date".to_string(),
|
||||||
"datetime".to_string(),
|
"datetime".to_string(),
|
||||||
"blob".to_string(),
|
|
||||||
"serial".to_string(),
|
"serial".to_string(),
|
||||||
"shortid".to_string(),
|
"shortid".to_string(),
|
||||||
],
|
],
|
||||||
@@ -2517,8 +2532,8 @@ mod tests {
|
|||||||
// inside `Name`, and substituting any name there
|
// inside `Name`, and substituting any name there
|
||||||
// produces a complete command. No useful "next after
|
// produces a complete command. No useful "next after
|
||||||
// name" hint.
|
// name" hint.
|
||||||
let t = typing_name_at_cursor("add column to table T: Name (text)", 27)
|
let t =
|
||||||
.expect("should fire");
|
typing_name_at_cursor("add column to table T: Name (text)", 27).expect("should fire");
|
||||||
assert_eq!(t.next_after_name, None);
|
assert_eq!(t.next_after_name, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2534,8 +2549,8 @@ mod tests {
|
|||||||
assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none());
|
assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none());
|
||||||
// `show data Cust` plus a typo: `show data Custp`. No
|
// `show data Cust` plus a typo: `show data Custp`. No
|
||||||
// table starts with "Custp" → invalid.
|
// table starts with "Custp" → invalid.
|
||||||
let invalid = invalid_ident_at_cursor("show data Custp", 15, &cache)
|
let invalid =
|
||||||
.expect("should be invalid");
|
invalid_ident_at_cursor("show data Custp", 15, &cache).expect("should be invalid");
|
||||||
assert_eq!(invalid.range, (10, 15));
|
assert_eq!(invalid.range, (10, 15));
|
||||||
assert_eq!(invalid.found, "Custp");
|
assert_eq!(invalid.found, "Custp");
|
||||||
assert_eq!(invalid.source, IdentSource::Tables);
|
assert_eq!(invalid.source, IdentSource::Tables);
|
||||||
@@ -2600,7 +2615,11 @@ mod tests {
|
|||||||
!cs.iter().any(|c| c == "Existing" || c == "AlsoExisting"),
|
!cs.iter().any(|c| c == "Existing" || c == "AlsoExisting"),
|
||||||
"NewName slot must not surface schema candidates; got {cs:?}"
|
"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 {
|
fn keyword_cand(text: &str) -> Candidate {
|
||||||
@@ -2791,8 +2810,10 @@ mod tests {
|
|||||||
let cands = candidates_at_cursor(input, input.len(), &cache)
|
let cands = candidates_at_cursor(input, input.len(), &cache)
|
||||||
.expect("some completion")
|
.expect("some completion")
|
||||||
.candidates;
|
.candidates;
|
||||||
let count_entries: Vec<_> =
|
let count_entries: Vec<_> = cands
|
||||||
cands.iter().filter(|c| c.text.eq_ignore_ascii_case("count")).collect();
|
.iter()
|
||||||
|
.filter(|c| c.text.eq_ignore_ascii_case("count"))
|
||||||
|
.collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
count_entries.len(),
|
count_entries.len(),
|
||||||
1,
|
1,
|
||||||
@@ -2805,7 +2826,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// A non-colliding function at the same slot is unaffected.
|
// A non-colliding function at the same slot is unaffected.
|
||||||
assert!(
|
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:?}",
|
"non-colliding functions still surface; got {cands:?}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2875,8 +2898,10 @@ mod tests {
|
|||||||
let mut s = SchemaCache::default();
|
let mut s = SchemaCache::default();
|
||||||
s.tables.push("OrderLines".into());
|
s.tables.push("OrderLines".into());
|
||||||
s.columns.push("count".into());
|
s.columns.push("count".into());
|
||||||
s.table_columns
|
s.table_columns.insert(
|
||||||
.insert("OrderLines".into(), vec![TableColumn::new("count", Type::Int)]);
|
"OrderLines".into(),
|
||||||
|
vec![TableColumn::new("count", Type::Int)],
|
||||||
|
);
|
||||||
let input = "select sum(ol.count) from OrderLines ol";
|
let input = "select sum(ol.count) from OrderLines ol";
|
||||||
let cursor = input.find("ol.count").unwrap() + 2; // right after `ol`
|
let cursor = input.find("ol.count").unwrap() + 2; // right after `ol`
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2938,15 +2963,35 @@ mod tests {
|
|||||||
s.table_columns.insert(
|
s.table_columns.insert(
|
||||||
"a".to_string(),
|
"a".to_string(),
|
||||||
vec![
|
vec![
|
||||||
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
|
TableColumn {
|
||||||
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
|
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(
|
s.table_columns.insert(
|
||||||
"b".to_string(),
|
"b".to_string(),
|
||||||
vec![
|
vec![
|
||||||
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
|
TableColumn {
|
||||||
TableColumn { name: "total".to_string(), user_type: Type::Real, not_null: false, has_default: false },
|
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
|
s
|
||||||
@@ -3191,5 +3236,3 @@ mod tests {
|
|||||||
assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none());
|
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
|
/// word like `insert` / `create` / `show`, or `types`), the
|
||||||
/// focused detail for that command (or command group sharing
|
/// focused detail for that command (or command group sharing
|
||||||
/// the entry word).
|
/// the entry word).
|
||||||
Help {
|
Help { topic: Option<String> },
|
||||||
topic: Option<String>,
|
|
||||||
},
|
|
||||||
/// Show a contextual tier-3 hint (H2 / ADR-0053). No argument:
|
/// Show a contextual tier-3 hint (H2 / ADR-0053). No argument:
|
||||||
/// when submitted, it expands on the most recent runtime error
|
/// when submitted, it expands on the most recent runtime error
|
||||||
/// (the buffer is empty post-submit). The live-input surface is
|
/// (the buffer is empty post-submit). The live-input surface is
|
||||||
/// the F1 keybinding, handled in `App::handle_key`, not here.
|
/// the F1 keybinding, handled in `App::handle_key`, not here.
|
||||||
Hint,
|
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
|
/// Rebuild `playground.db` from `project.yaml` + data/, with
|
||||||
/// confirmation modal.
|
/// confirmation modal.
|
||||||
Rebuild,
|
Rebuild,
|
||||||
@@ -576,7 +578,10 @@ pub enum AppCommand {
|
|||||||
/// Unpack a zip into a new project and switch to it.
|
/// Unpack a zip into a new project and switch to it.
|
||||||
/// `target` overrides the project name (default: taken from
|
/// `target` overrides the project name (default: taken from
|
||||||
/// the zip).
|
/// the zip).
|
||||||
Import { path: String, target: Option<String> },
|
Import {
|
||||||
|
path: String,
|
||||||
|
target: Option<String>,
|
||||||
|
},
|
||||||
/// Switch the persistent input mode.
|
/// Switch the persistent input mode.
|
||||||
Mode { value: ModeValue },
|
Mode { value: ModeValue },
|
||||||
/// Show or set the messages verbosity.
|
/// Show or set the messages verbosity.
|
||||||
@@ -787,9 +792,7 @@ impl PartialEq for Operand {
|
|||||||
fn eq(&self, other: &Self) -> bool {
|
fn eq(&self, other: &Self) -> bool {
|
||||||
match (self, other) {
|
match (self, other) {
|
||||||
(Self::Column { name: a, .. }, Self::Column { name: b, .. }) => a == b,
|
(Self::Column { name: a, .. }, Self::Column { name: b, .. }) => a == b,
|
||||||
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => {
|
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => a == b,
|
||||||
a == b
|
|
||||||
}
|
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -813,7 +816,9 @@ pub enum CompareOp {
|
|||||||
/// a single row in the metadata table.
|
/// a single row in the metadata table.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum RelationshipSelector {
|
pub enum RelationshipSelector {
|
||||||
Named { name: String },
|
Named {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
Endpoints {
|
Endpoints {
|
||||||
parent_table: String,
|
parent_table: String,
|
||||||
parent_column: String,
|
parent_column: String,
|
||||||
@@ -1019,6 +1024,7 @@ impl Command {
|
|||||||
AppCommand::Quit => "quit",
|
AppCommand::Quit => "quit",
|
||||||
AppCommand::Help { .. } => "help",
|
AppCommand::Help { .. } => "help",
|
||||||
AppCommand::Hint => "hint",
|
AppCommand::Hint => "hint",
|
||||||
|
AppCommand::Version => "version",
|
||||||
AppCommand::Rebuild => "rebuild",
|
AppCommand::Rebuild => "rebuild",
|
||||||
AppCommand::Save => "save",
|
AppCommand::Save => "save",
|
||||||
AppCommand::SaveAs => "save as",
|
AppCommand::SaveAs => "save as",
|
||||||
@@ -1151,9 +1157,7 @@ impl Command {
|
|||||||
parent_column,
|
parent_column,
|
||||||
child_table,
|
child_table,
|
||||||
child_column,
|
child_column,
|
||||||
} => format!(
|
} => format!("from {parent_table}.{parent_column} to {child_table}.{child_column}"),
|
||||||
"from {parent_table}.{parent_column} to {child_table}.{child_column}"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
// A constraint command's subject is the dotted
|
// A constraint command's subject is the dotted
|
||||||
// `<table>.<column>` it acts on (ADR-0029 §2.2).
|
// `<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::command::{AppCommand, Command, CopyScope, MessagesValue, ModeValue};
|
||||||
use crate::dsl::grammar::{
|
use crate::dsl::grammar::{
|
||||||
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError,
|
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError, Word,
|
||||||
Word,
|
|
||||||
};
|
};
|
||||||
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
|
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
|
||||||
|
|
||||||
@@ -60,19 +59,16 @@ const IMPORT_TARGET_IDENT: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
const IMPORT_TARGET: Node = Node::Hinted {
|
const IMPORT_TARGET: Node = Node::Hinted {
|
||||||
mode: HintMode::ForceProse("hint.ambient_typing_name"),
|
mode: HintMode::ForceProse("hint.ambient_typing_name"),
|
||||||
inner: &IMPORT_TARGET_IDENT,
|
inner: &IMPORT_TARGET_IDENT,
|
||||||
};
|
};
|
||||||
|
|
||||||
const IMPORT_AS_TARGET: Node = Node::Seq(&[
|
const IMPORT_AS_TARGET: Node = Node::Seq(&[Node::Word(Word::keyword("as")), IMPORT_TARGET]);
|
||||||
Node::Word(Word::keyword("as")),
|
|
||||||
IMPORT_TARGET,
|
|
||||||
]);
|
|
||||||
const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_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]);
|
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))
|
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> {
|
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||||
Ok(Command::App(AppCommand::Undo))
|
Ok(Command::App(AppCommand::Undo))
|
||||||
}
|
}
|
||||||
@@ -267,7 +267,8 @@ pub static QUIT: CommandNode = CommandNode {
|
|||||||
ast_builder: build_quit,
|
ast_builder: build_quit,
|
||||||
help_id: Some("app.quit"),
|
help_id: Some("app.quit"),
|
||||||
hint_ids: &["quit"],
|
hint_ids: &["quit"],
|
||||||
usage_ids: &["parse.usage.quit"],};
|
usage_ids: &["parse.usage.quit"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static HELP: CommandNode = CommandNode {
|
pub static HELP: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("help"),
|
entry: Word::keyword("help"),
|
||||||
@@ -275,7 +276,8 @@ pub static HELP: CommandNode = CommandNode {
|
|||||||
ast_builder: build_help,
|
ast_builder: build_help,
|
||||||
help_id: Some("app.help"),
|
help_id: Some("app.help"),
|
||||||
hint_ids: &["help"],
|
hint_ids: &["help"],
|
||||||
usage_ids: &["parse.usage.help"],};
|
usage_ids: &["parse.usage.help"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static HINT: CommandNode = CommandNode {
|
pub static HINT: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("hint"),
|
entry: Word::keyword("hint"),
|
||||||
@@ -284,7 +286,8 @@ pub static HINT: CommandNode = CommandNode {
|
|||||||
help_id: Some("app.hint"),
|
help_id: Some("app.hint"),
|
||||||
// hint_id assigned in Phase C with the tier-3 corpus (ADR-0053).
|
// hint_id assigned in Phase C with the tier-3 corpus (ADR-0053).
|
||||||
hint_ids: &["hint"],
|
hint_ids: &["hint"],
|
||||||
usage_ids: &["parse.usage.hint"],};
|
usage_ids: &["parse.usage.hint"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static REBUILD: CommandNode = CommandNode {
|
pub static REBUILD: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("rebuild"),
|
entry: Word::keyword("rebuild"),
|
||||||
@@ -292,7 +295,17 @@ pub static REBUILD: CommandNode = CommandNode {
|
|||||||
ast_builder: build_rebuild,
|
ast_builder: build_rebuild,
|
||||||
help_id: Some("app.rebuild"),
|
help_id: Some("app.rebuild"),
|
||||||
hint_ids: &["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 {
|
pub static SAVE: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("save"),
|
entry: Word::keyword("save"),
|
||||||
@@ -300,7 +313,8 @@ pub static SAVE: CommandNode = CommandNode {
|
|||||||
ast_builder: build_save,
|
ast_builder: build_save,
|
||||||
help_id: Some("app.save"),
|
help_id: Some("app.save"),
|
||||||
hint_ids: &["save"],
|
hint_ids: &["save"],
|
||||||
usage_ids: &["parse.usage.save"],};
|
usage_ids: &["parse.usage.save"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static NEW: CommandNode = CommandNode {
|
pub static NEW: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("new"),
|
entry: Word::keyword("new"),
|
||||||
@@ -308,7 +322,8 @@ pub static NEW: CommandNode = CommandNode {
|
|||||||
ast_builder: build_new,
|
ast_builder: build_new,
|
||||||
help_id: Some("app.new"),
|
help_id: Some("app.new"),
|
||||||
hint_ids: &["new"],
|
hint_ids: &["new"],
|
||||||
usage_ids: &["parse.usage.new"],};
|
usage_ids: &["parse.usage.new"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static LOAD: CommandNode = CommandNode {
|
pub static LOAD: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("load"),
|
entry: Word::keyword("load"),
|
||||||
@@ -316,7 +331,8 @@ pub static LOAD: CommandNode = CommandNode {
|
|||||||
ast_builder: build_load,
|
ast_builder: build_load,
|
||||||
help_id: Some("app.load"),
|
help_id: Some("app.load"),
|
||||||
hint_ids: &["load"],
|
hint_ids: &["load"],
|
||||||
usage_ids: &["parse.usage.load"],};
|
usage_ids: &["parse.usage.load"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static EXPORT: CommandNode = CommandNode {
|
pub static EXPORT: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("export"),
|
entry: Word::keyword("export"),
|
||||||
@@ -324,7 +340,8 @@ pub static EXPORT: CommandNode = CommandNode {
|
|||||||
ast_builder: build_export,
|
ast_builder: build_export,
|
||||||
help_id: Some("app.export"),
|
help_id: Some("app.export"),
|
||||||
hint_ids: &["export"],
|
hint_ids: &["export"],
|
||||||
usage_ids: &["parse.usage.export"],};
|
usage_ids: &["parse.usage.export"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static IMPORT: CommandNode = CommandNode {
|
pub static IMPORT: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("import"),
|
entry: Word::keyword("import"),
|
||||||
@@ -332,7 +349,8 @@ pub static IMPORT: CommandNode = CommandNode {
|
|||||||
ast_builder: build_import,
|
ast_builder: build_import,
|
||||||
help_id: Some("app.import"),
|
help_id: Some("app.import"),
|
||||||
hint_ids: &["import"],
|
hint_ids: &["import"],
|
||||||
usage_ids: &["parse.usage.import"],};
|
usage_ids: &["parse.usage.import"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static MODE: CommandNode = CommandNode {
|
pub static MODE: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("mode"),
|
entry: Word::keyword("mode"),
|
||||||
@@ -340,7 +358,8 @@ pub static MODE: CommandNode = CommandNode {
|
|||||||
ast_builder: build_mode,
|
ast_builder: build_mode,
|
||||||
help_id: Some("app.mode"),
|
help_id: Some("app.mode"),
|
||||||
hint_ids: &["mode"],
|
hint_ids: &["mode"],
|
||||||
usage_ids: &["parse.usage.mode"],};
|
usage_ids: &["parse.usage.mode"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static MESSAGES: CommandNode = CommandNode {
|
pub static MESSAGES: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("messages"),
|
entry: Word::keyword("messages"),
|
||||||
@@ -348,7 +367,8 @@ pub static MESSAGES: CommandNode = CommandNode {
|
|||||||
ast_builder: build_messages,
|
ast_builder: build_messages,
|
||||||
help_id: Some("app.messages"),
|
help_id: Some("app.messages"),
|
||||||
hint_ids: &["messages"],
|
hint_ids: &["messages"],
|
||||||
usage_ids: &["parse.usage.messages"],};
|
usage_ids: &["parse.usage.messages"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static UNDO: CommandNode = CommandNode {
|
pub static UNDO: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("undo"),
|
entry: Word::keyword("undo"),
|
||||||
@@ -356,7 +376,8 @@ pub static UNDO: CommandNode = CommandNode {
|
|||||||
ast_builder: build_undo,
|
ast_builder: build_undo,
|
||||||
help_id: Some("app.undo"),
|
help_id: Some("app.undo"),
|
||||||
hint_ids: &["undo"],
|
hint_ids: &["undo"],
|
||||||
usage_ids: &["parse.usage.undo"],};
|
usage_ids: &["parse.usage.undo"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static REDO: CommandNode = CommandNode {
|
pub static REDO: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("redo"),
|
entry: Word::keyword("redo"),
|
||||||
@@ -364,7 +385,8 @@ pub static REDO: CommandNode = CommandNode {
|
|||||||
ast_builder: build_redo,
|
ast_builder: build_redo,
|
||||||
help_id: Some("app.redo"),
|
help_id: Some("app.redo"),
|
||||||
hint_ids: &["redo"],
|
hint_ids: &["redo"],
|
||||||
usage_ids: &["parse.usage.redo"],};
|
usage_ids: &["parse.usage.redo"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static COPY: CommandNode = CommandNode {
|
pub static COPY: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("copy"),
|
entry: Word::keyword("copy"),
|
||||||
@@ -372,4 +394,5 @@ pub static COPY: CommandNode = CommandNode {
|
|||||||
ast_builder: build_copy,
|
ast_builder: build_copy,
|
||||||
help_id: Some("app.copy"),
|
help_id: Some("app.copy"),
|
||||||
hint_ids: &["copy"],
|
hint_ids: &["copy"],
|
||||||
usage_ids: &["parse.usage.copy"],};
|
usage_ids: &["parse.usage.copy"],
|
||||||
|
};
|
||||||
|
|||||||
+110
-87
@@ -24,19 +24,17 @@
|
|||||||
//! later swap that capture for the same typed slots used here, adding
|
//! later swap that capture for the same typed slots used here, adding
|
||||||
//! live hints/highlighting.
|
//! live hints/highlighting.
|
||||||
|
|
||||||
use crate::dsl::command::{
|
use crate::dsl::command::{Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind};
|
||||||
Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind,
|
|
||||||
};
|
|
||||||
use crate::dsl::grammar::{
|
use crate::dsl::grammar::{
|
||||||
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
|
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
|
||||||
shared::{
|
shared::{
|
||||||
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values,
|
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values, current_column_value,
|
||||||
current_column_value, insert_target_columns,
|
insert_target_columns,
|
||||||
},
|
},
|
||||||
sql_delete, sql_insert, sql_select, sql_update,
|
sql_delete, sql_insert, sql_select, sql_update,
|
||||||
};
|
};
|
||||||
use crate::dsl::walker::context::WalkContext;
|
|
||||||
use crate::dsl::value::Value;
|
use crate::dsl::value::Value;
|
||||||
|
use crate::dsl::walker::context::WalkContext;
|
||||||
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
|
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -57,9 +55,9 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Table-name slot variant that populates
|
/// Table-name slot variant that populates
|
||||||
@@ -76,9 +74,9 @@ const TABLE_NAME_INSERT: Node = Node::Ident {
|
|||||||
writes_table: true,
|
writes_table: true,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: 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_DATA: Node = Node::Seq(SHOW_DATA_NODES);
|
||||||
|
|
||||||
const SHOW_TABLE_NODES: &[Node] = &[
|
const SHOW_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
|
||||||
Node::Word(Word::keyword("table")),
|
|
||||||
TABLE_NAME_EXISTING,
|
|
||||||
];
|
|
||||||
const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES);
|
const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES);
|
||||||
|
|
||||||
// `show tables` / `show relationships` / `show indexes` — the
|
// `show tables` / `show relationships` / `show indexes` — the
|
||||||
@@ -144,8 +139,7 @@ const SHOW_INDEX_NAME: Node = Node::Ident {
|
|||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
const SHOW_INDEX_NODES: &[Node] =
|
const SHOW_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
|
||||||
&[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
|
|
||||||
const SHOW_INDEX: Node = Node::Seq(SHOW_INDEX_NODES);
|
const SHOW_INDEX: Node = Node::Seq(SHOW_INDEX_NODES);
|
||||||
|
|
||||||
const SHOW_CHOICES: &[Node] = &[
|
const SHOW_CHOICES: &[Node] = &[
|
||||||
@@ -192,9 +186,9 @@ static FORM_A_COLUMN: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: true,
|
writes_user_listed_column: true,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
static INSERT_COMMA: Node = Node::Punct(',');
|
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.
|
/// or an identifier-shaped token (a column name) returns false.
|
||||||
fn first_paren_item_is_value_literal(source: &str, pos: usize) -> bool {
|
fn first_paren_item_is_value_literal(source: &str, pos: usize) -> bool {
|
||||||
use crate::dsl::walker::lex_helpers::{
|
use crate::dsl::walker::lex_helpers::{
|
||||||
consume_ident, consume_number_literal, consume_string_literal,
|
consume_ident, consume_number_literal, consume_string_literal, skip_whitespace,
|
||||||
skip_whitespace,
|
|
||||||
};
|
};
|
||||||
let p = skip_whitespace(source, pos);
|
let p = skip_whitespace(source, pos);
|
||||||
if p >= source.len() {
|
if p >= source.len() {
|
||||||
@@ -281,7 +274,11 @@ fn dsl_insert_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
|
|||||||
return FALLBACK_VALUE_LIST;
|
return FALLBACK_VALUE_LIST;
|
||||||
};
|
};
|
||||||
let (count, closed) = count_tuple_values(source, pos);
|
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 {
|
if arity_ok {
|
||||||
Node::DynamicSubgrammar(column_value_list)
|
Node::DynamicSubgrammar(column_value_list)
|
||||||
} else {
|
} 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_VALUES_KEYWORD_FIRST: Node = Node::Seq(INSERT_VALUES_KEYWORD_FIRST_NODES);
|
||||||
|
|
||||||
const INSERT_AFTER_TABLE_CHOICES: &[Node] =
|
const INSERT_AFTER_TABLE_CHOICES: &[Node] = &[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
|
||||||
&[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
|
|
||||||
const INSERT_AFTER_TABLE: Node = Node::Choice(INSERT_AFTER_TABLE_CHOICES);
|
const INSERT_AFTER_TABLE: Node = Node::Choice(INSERT_AFTER_TABLE_CHOICES);
|
||||||
|
|
||||||
const INSERT_NODES: &[Node] = &[
|
const INSERT_NODES: &[Node] = &[
|
||||||
@@ -350,9 +346,9 @@ const TABLE_NAME_WRITES: Node = Node::Ident {
|
|||||||
writes_table: true,
|
writes_table: true,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Column-name slot in `set col = …` — resolves the column's
|
/// Column-name slot in `set col = …` — resolves the column's
|
||||||
@@ -366,9 +362,9 @@ const SET_COLUMN: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: true,
|
writes_column: true,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Value slot resolved at walk time from
|
/// Value slot resolved at walk time from
|
||||||
@@ -376,11 +372,7 @@ writes_projection_alias: false,
|
|||||||
/// value-literal choice when no current_column is bound.
|
/// value-literal choice when no current_column is bound.
|
||||||
const PER_COLUMN_VALUE: Node = Node::DynamicSubgrammar(current_column_value);
|
const PER_COLUMN_VALUE: Node = Node::DynamicSubgrammar(current_column_value);
|
||||||
|
|
||||||
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[
|
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[SET_COLUMN, Node::Punct('='), PER_COLUMN_VALUE];
|
||||||
SET_COLUMN,
|
|
||||||
Node::Punct('='),
|
|
||||||
PER_COLUMN_VALUE,
|
|
||||||
];
|
|
||||||
const UPDATE_ASSIGNMENT: Node = Node::Seq(UPDATE_ASSIGNMENT_NODES);
|
const UPDATE_ASSIGNMENT: Node = Node::Seq(UPDATE_ASSIGNMENT_NODES);
|
||||||
const UPDATE_ASSIGNMENTS: Node = Node::Repeated {
|
const UPDATE_ASSIGNMENTS: Node = Node::Repeated {
|
||||||
inner: &UPDATE_ASSIGNMENT,
|
inner: &UPDATE_ASSIGNMENT,
|
||||||
@@ -568,8 +560,7 @@ const SEED_OVERRIDES: Node = Node::Repeated {
|
|||||||
separator: Some(&Node::Punct(',')),
|
separator: Some(&Node::Punct(',')),
|
||||||
min: 1,
|
min: 1,
|
||||||
};
|
};
|
||||||
const SEED_SET_CLAUSE_NODES: &[Node] =
|
const SEED_SET_CLAUSE_NODES: &[Node] = &[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
|
||||||
&[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
|
|
||||||
const SEED_SET_CLAUSE: Node = Node::Seq(SEED_SET_CLAUSE_NODES);
|
const SEED_SET_CLAUSE: Node = Node::Seq(SEED_SET_CLAUSE_NODES);
|
||||||
|
|
||||||
const SEED_NODES: &[Node] = &[
|
const SEED_NODES: &[Node] = &[
|
||||||
@@ -980,7 +971,10 @@ fn parse_seed_override_tail(
|
|||||||
MatchedKind::Word("in") => {
|
MatchedKind::Word("in") => {
|
||||||
*i += 1; // `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;
|
*i += 1;
|
||||||
}
|
}
|
||||||
let mut values = Vec::new();
|
let mut values = Vec::new();
|
||||||
@@ -1001,7 +995,10 @@ fn parse_seed_override_tail(
|
|||||||
MatchedKind::Word("between") => {
|
MatchedKind::Word("between") => {
|
||||||
*i += 1; // `between`
|
*i += 1; // `between`
|
||||||
let low = seed_take_value(region, i, column)?;
|
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;
|
*i += 1;
|
||||||
}
|
}
|
||||||
let high = seed_take_value(region, i, column)?;
|
let high = seed_take_value(region, i, column)?;
|
||||||
@@ -1011,7 +1008,15 @@ fn parse_seed_override_tail(
|
|||||||
*i += 1; // `as`
|
*i += 1; // `as`
|
||||||
let gen_item = region
|
let gen_item = region
|
||||||
.get(*i)
|
.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))?;
|
.ok_or_else(|| seed_set_error(column))?;
|
||||||
*i += 1;
|
*i += 1;
|
||||||
Ok(SeedOverrideKind::Generator(gen_item.text.clone()))
|
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
|
let table_idx = path
|
||||||
.items
|
.items
|
||||||
.iter()
|
.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 {
|
.ok_or_else(|| ValidationError {
|
||||||
message_key: "parse.error_wrapper",
|
message_key: "parse.error_wrapper",
|
||||||
args: vec![("detail", "missing table".to_string())],
|
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() {
|
if columns.is_empty() {
|
||||||
return Err(ValidationError {
|
return Err(ValidationError {
|
||||||
message_key: "parse.error_wrapper",
|
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
|
// 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(
|
fn collect_assignments(path: &MatchedPath) -> Result<Vec<(String, Value)>, ValidationError> {
|
||||||
path: &MatchedPath,
|
|
||||||
) -> Result<Vec<(String, Value)>, ValidationError> {
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut iter = path.items.iter();
|
let mut iter = path.items.iter();
|
||||||
while let Some(item) = iter.next() {
|
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
|
let row_source = path
|
||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
.find(|item| {
|
.find(|item| matches!(item.kind, MatchedKind::Word("values" | "select" | "with")))
|
||||||
matches!(item.kind, MatchedKind::Word("values" | "select" | "with"))
|
|
||||||
})
|
|
||||||
.map(|item| {
|
.map(|item| {
|
||||||
let end = tail_start.unwrap_or(source.len());
|
let end = tail_start.unwrap_or(source.len());
|
||||||
source[item.span.0..end]
|
source[item.span.0..end]
|
||||||
@@ -1805,7 +1817,8 @@ pub static SHOW: CommandNode = CommandNode {
|
|||||||
"parse.usage.show_indexes",
|
"parse.usage.show_indexes",
|
||||||
"parse.usage.show_relationship",
|
"parse.usage.show_relationship",
|
||||||
"parse.usage.show_index",
|
"parse.usage.show_index",
|
||||||
],};
|
],
|
||||||
|
};
|
||||||
|
|
||||||
pub static SEED: CommandNode = CommandNode {
|
pub static SEED: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("seed"),
|
entry: Word::keyword("seed"),
|
||||||
@@ -1823,7 +1836,8 @@ pub static INSERT: CommandNode = CommandNode {
|
|||||||
help_id: Some("data.insert"),
|
help_id: Some("data.insert"),
|
||||||
// ADR-0053 Phase-B exemplar.
|
// ADR-0053 Phase-B exemplar.
|
||||||
hint_ids: &["insert"],
|
hint_ids: &["insert"],
|
||||||
usage_ids: &["parse.usage.insert"],};
|
usage_ids: &["parse.usage.insert"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static UPDATE: CommandNode = CommandNode {
|
pub static UPDATE: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("update"),
|
entry: Word::keyword("update"),
|
||||||
@@ -1831,7 +1845,8 @@ pub static UPDATE: CommandNode = CommandNode {
|
|||||||
ast_builder: build_update,
|
ast_builder: build_update,
|
||||||
help_id: Some("data.update"),
|
help_id: Some("data.update"),
|
||||||
hint_ids: &["update"],
|
hint_ids: &["update"],
|
||||||
usage_ids: &["parse.usage.update"],};
|
usage_ids: &["parse.usage.update"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static DELETE: CommandNode = CommandNode {
|
pub static DELETE: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("delete"),
|
entry: Word::keyword("delete"),
|
||||||
@@ -1839,7 +1854,8 @@ pub static DELETE: CommandNode = CommandNode {
|
|||||||
ast_builder: build_delete,
|
ast_builder: build_delete,
|
||||||
help_id: Some("data.delete"),
|
help_id: Some("data.delete"),
|
||||||
hint_ids: &["delete"],
|
hint_ids: &["delete"],
|
||||||
usage_ids: &["parse.usage.delete"],};
|
usage_ids: &["parse.usage.delete"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static REPLAY: CommandNode = CommandNode {
|
pub static REPLAY: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("replay"),
|
entry: Word::keyword("replay"),
|
||||||
@@ -1847,7 +1863,8 @@ pub static REPLAY: CommandNode = CommandNode {
|
|||||||
ast_builder: build_replay,
|
ast_builder: build_replay,
|
||||||
help_id: Some("data.replay"),
|
help_id: Some("data.replay"),
|
||||||
hint_ids: &["replay"],
|
hint_ids: &["replay"],
|
||||||
usage_ids: &["parse.usage.replay"],};
|
usage_ids: &["parse.usage.replay"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static EXPLAIN: CommandNode = CommandNode {
|
pub static EXPLAIN: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("explain"),
|
entry: Word::keyword("explain"),
|
||||||
@@ -1855,7 +1872,8 @@ pub static EXPLAIN: CommandNode = CommandNode {
|
|||||||
ast_builder: build_explain,
|
ast_builder: build_explain,
|
||||||
help_id: Some("data.explain"),
|
help_id: Some("data.explain"),
|
||||||
hint_ids: &["explain"],
|
hint_ids: &["explain"],
|
||||||
usage_ids: &["parse.usage.explain"],};
|
usage_ids: &["parse.usage.explain"],
|
||||||
|
};
|
||||||
|
|
||||||
/// `explain` over advanced-mode SQL (ADR-0039).
|
/// `explain` over advanced-mode SQL (ADR-0039).
|
||||||
///
|
///
|
||||||
@@ -1868,14 +1886,15 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
|
|||||||
entry: Word::keyword("explain"),
|
entry: Word::keyword("explain"),
|
||||||
shape: EXPLAIN_SQL_SHAPE,
|
shape: EXPLAIN_SQL_SHAPE,
|
||||||
ast_builder: build_explain_sql,
|
ast_builder: build_explain_sql,
|
||||||
// No `help_id` / `usage_ids` — this is the `Advanced` half of the
|
// Issue #36: its own `help` page (`data.explain_sql`), listed under the
|
||||||
// shared `explain` entry word, so it defers to the `Simple`
|
// advanced-mode (SQL) section and shown by `help explain` next to the
|
||||||
// `EXPLAIN` node's help/usage (which now covers the SQL forms
|
// simple `EXPLAIN` form (distinct help_id ⇒ no dedup clash). `usage_ids`
|
||||||
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE`
|
// stays empty — the `Simple` `EXPLAIN` node's usage block already covers
|
||||||
// precedent; otherwise `note_help` would print `explain` twice.
|
// the shared `explain` entry word.
|
||||||
help_id: None,
|
help_id: Some("data.explain_sql"),
|
||||||
hint_ids: &["explain_sql"],
|
hint_ids: &["explain_sql"],
|
||||||
usage_ids: &[],};
|
usage_ids: &[],
|
||||||
|
};
|
||||||
|
|
||||||
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
|
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
|
||||||
///
|
///
|
||||||
@@ -1883,15 +1902,16 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
|
|||||||
/// The shape is the post-`SELECT` portion of a top-level
|
/// The shape is the post-`SELECT` portion of a top-level
|
||||||
/// statement; the registry's entry-word dispatch consumes the
|
/// statement; the registry's entry-word dispatch consumes the
|
||||||
/// leading `SELECT` keyword before the shape walks (sub-phase
|
/// leading `SELECT` keyword before the shape walks (sub-phase
|
||||||
/// 2c migration). `help_id` is `None` until the `help sql`
|
/// 2c migration). Carries its own `help` page (`data.select`),
|
||||||
/// page lands (ADR-0030 Phase 6).
|
/// listed under the advanced-mode (SQL) section (issue #36).
|
||||||
pub static SELECT: CommandNode = CommandNode {
|
pub static SELECT: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("select"),
|
entry: Word::keyword("select"),
|
||||||
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
|
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
|
||||||
ast_builder: build_select,
|
ast_builder: build_select,
|
||||||
help_id: None,
|
help_id: Some("data.select"),
|
||||||
hint_ids: &["select"],
|
hint_ids: &["select"],
|
||||||
usage_ids: &["parse.usage.select"],};
|
usage_ids: &["parse.usage.select"],
|
||||||
|
};
|
||||||
|
|
||||||
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
|
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
|
||||||
///
|
///
|
||||||
@@ -1904,9 +1924,10 @@ pub static WITH: CommandNode = CommandNode {
|
|||||||
entry: Word::keyword("with"),
|
entry: Word::keyword("with"),
|
||||||
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
|
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
|
||||||
ast_builder: build_select,
|
ast_builder: build_select,
|
||||||
help_id: None,
|
help_id: Some("data.with"), // issue #36: own help page, advanced section
|
||||||
hint_ids: &["with"],
|
hint_ids: &["with"],
|
||||||
usage_ids: &["parse.usage.with"],};
|
usage_ids: &["parse.usage.with"],
|
||||||
|
};
|
||||||
|
|
||||||
/// SQL `INSERT` — the `Advanced`-category node of the shared
|
/// SQL `INSERT` — the `Advanced`-category node of the shared
|
||||||
/// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j).
|
/// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j).
|
||||||
@@ -1922,7 +1943,7 @@ pub static SQL_INSERT: CommandNode = CommandNode {
|
|||||||
entry: Word::keyword("insert"),
|
entry: Word::keyword("insert"),
|
||||||
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
|
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
|
||||||
ast_builder: build_sql_insert,
|
ast_builder: build_sql_insert,
|
||||||
help_id: None,
|
help_id: Some("data.sql_insert"), // issue #36: own help page, advanced section
|
||||||
hint_ids: &["sql_insert"],
|
hint_ids: &["sql_insert"],
|
||||||
usage_ids: &[],
|
usage_ids: &[],
|
||||||
};
|
};
|
||||||
@@ -1936,7 +1957,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
|
|||||||
entry: Word::keyword("update"),
|
entry: Word::keyword("update"),
|
||||||
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
|
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
|
||||||
ast_builder: build_sql_update,
|
ast_builder: build_sql_update,
|
||||||
help_id: None,
|
help_id: Some("data.sql_update"), // issue #36: own help page, advanced section
|
||||||
hint_ids: &["sql_update"],
|
hint_ids: &["sql_update"],
|
||||||
usage_ids: &[],
|
usage_ids: &[],
|
||||||
};
|
};
|
||||||
@@ -1952,7 +1973,7 @@ pub static SQL_DELETE: CommandNode = CommandNode {
|
|||||||
entry: Word::keyword("delete"),
|
entry: Word::keyword("delete"),
|
||||||
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
|
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
|
||||||
ast_builder: build_sql_delete,
|
ast_builder: build_sql_delete,
|
||||||
help_id: None,
|
help_id: Some("data.sql_delete"), // issue #36: own help page, advanced section
|
||||||
hint_ids: &["sql_delete"],
|
hint_ids: &["sql_delete"],
|
||||||
usage_ids: &[],
|
usage_ids: &[],
|
||||||
};
|
};
|
||||||
@@ -1993,7 +2014,11 @@ mod explain_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn explain_show_data_carries_where_and_limit_through() {
|
fn explain_show_data_carries_where_and_limit_through() {
|
||||||
match explain_inner("explain show data Customers where id = 1 limit 5") {
|
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_eq!(name, "Customers");
|
||||||
assert!(filter.is_some(), "where clause should survive");
|
assert!(filter.is_some(), "where clause should survive");
|
||||||
assert_eq!(limit, Some(5));
|
assert_eq!(limit, Some(5));
|
||||||
@@ -2052,9 +2077,7 @@ mod explain_tests {
|
|||||||
|
|
||||||
/// Advanced-mode counterpart of `explain_inner`.
|
/// Advanced-mode counterpart of `explain_inner`.
|
||||||
fn explain_inner_adv(input: &str) -> Command {
|
fn explain_inner_adv(input: &str) -> Command {
|
||||||
match parse_command_in_mode(input, Mode::Advanced)
|
match parse_command_in_mode(input, Mode::Advanced).expect("advanced explain should parse") {
|
||||||
.expect("advanced explain should parse")
|
|
||||||
{
|
|
||||||
Command::Explain { query } => *query,
|
Command::Explain { query } => *query,
|
||||||
other => panic!("expected Command::Explain, got {other:?}"),
|
other => panic!("expected Command::Explain, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -2085,7 +2108,9 @@ mod explain_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn explain_sql_insert_wraps_a_sql_insert() {
|
fn explain_sql_insert_wraps_a_sql_insert() {
|
||||||
match explain_inner_adv("explain insert into Customers values (1, 'Bo')") {
|
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!(target_table, "Customers");
|
||||||
assert_eq!(sql, "insert into Customers values (1, 'Bo')");
|
assert_eq!(sql, "insert into Customers values (1, 'Bo')");
|
||||||
}
|
}
|
||||||
@@ -2096,7 +2121,9 @@ mod explain_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn explain_sql_update_wraps_a_sql_update_with_clean_sql() {
|
fn explain_sql_update_wraps_a_sql_update_with_clean_sql() {
|
||||||
match explain_inner_adv("explain update Customers set Name = 'Bo' where id = 1") {
|
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!(target_table, "Customers");
|
||||||
assert_eq!(sql, "update Customers set Name = 'Bo' where id = 1");
|
assert_eq!(sql, "update Customers set Name = 'Bo' where id = 1");
|
||||||
}
|
}
|
||||||
@@ -2107,7 +2134,9 @@ mod explain_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn explain_sql_delete_wraps_a_sql_delete() {
|
fn explain_sql_delete_wraps_a_sql_delete() {
|
||||||
match explain_inner_adv("explain delete from Customers where id = 1") {
|
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!(target_table, "Customers");
|
||||||
assert_eq!(sql, "delete from Customers where id = 1");
|
assert_eq!(sql, "delete from Customers where id = 1");
|
||||||
}
|
}
|
||||||
@@ -2148,11 +2177,7 @@ mod explain_tests {
|
|||||||
fn explain_does_not_cover_ddl() {
|
fn explain_does_not_cover_ddl() {
|
||||||
// EXPLAIN QUERY PLAN applies to DML/queries only (ADR-0039
|
// EXPLAIN QUERY PLAN applies to DML/queries only (ADR-0039
|
||||||
// out of scope); there is no SQL DDL branch under explain.
|
// out of scope); there is no SQL DDL branch under explain.
|
||||||
assert!(parse_command_in_mode(
|
assert!(parse_command_in_mode("explain create table T (id int)", Mode::Advanced,).is_err());
|
||||||
"explain create table T (id int)",
|
|
||||||
Mode::Advanced,
|
|
||||||
)
|
|
||||||
.is_err());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2165,8 +2190,7 @@ mod explain_tests {
|
|||||||
use crate::completion::candidates_at_cursor_in_mode;
|
use crate::completion::candidates_at_cursor_in_mode;
|
||||||
let schema = crate::completion::SchemaCache::default();
|
let schema = crate::completion::SchemaCache::default();
|
||||||
let input = "explain ";
|
let input = "explain ";
|
||||||
let completion =
|
let completion = candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
|
||||||
candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
|
|
||||||
.expect("explain offers candidates");
|
.expect("explain offers candidates");
|
||||||
let names: Vec<&str> = completion
|
let names: Vec<&str> = completion
|
||||||
.candidates
|
.candidates
|
||||||
@@ -2178,4 +2202,3 @@ mod explain_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+263
-136
@@ -16,11 +16,11 @@ use crate::dsl::command::{
|
|||||||
AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
|
AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
|
||||||
IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint,
|
IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint,
|
||||||
};
|
};
|
||||||
use crate::dsl::value::Value;
|
|
||||||
use crate::dsl::grammar::{
|
use crate::dsl::grammar::{
|
||||||
CommandNode, HighlightClass, HintMode, IdentSource, Node, ValidationError, Word,
|
CommandNode, HighlightClass, HintMode, IdentSource, Node, ValidationError, Word,
|
||||||
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
|
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
|
||||||
};
|
};
|
||||||
|
use crate::dsl::value::Value;
|
||||||
|
|
||||||
/// `HintMode` annotation shared by every `NewName` ident slot:
|
/// `HintMode` annotation shared by every `NewName` ident slot:
|
||||||
/// the user is inventing a name, so the hint panel forces the
|
/// 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_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
const TABLE_NAME_NEW: Node = Node::Hinted {
|
const TABLE_NAME_NEW: Node = Node::Hinted {
|
||||||
mode: NEW_NAME_HINT,
|
mode: NEW_NAME_HINT,
|
||||||
@@ -66,9 +66,9 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
|
|||||||
writes_table: true,
|
writes_table: true,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLUMN_NAME: Node = Node::Ident {
|
const COLUMN_NAME: Node = Node::Ident {
|
||||||
@@ -79,9 +79,9 @@ const COLUMN_NAME: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
|
const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
|
||||||
@@ -92,9 +92,9 @@ const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
const COLUMN_NAME_NEW: Node = Node::Hinted {
|
const COLUMN_NAME_NEW: Node = Node::Hinted {
|
||||||
mode: NEW_NAME_HINT,
|
mode: NEW_NAME_HINT,
|
||||||
@@ -109,9 +109,9 @@ const RELATIONSHIP_NAME: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
|
const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
|
||||||
@@ -122,9 +122,9 @@ const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
|
const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
|
||||||
mode: NEW_NAME_HINT,
|
mode: NEW_NAME_HINT,
|
||||||
@@ -139,9 +139,9 @@ const INDEX_NAME_EXISTING: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
|
const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
|
||||||
@@ -152,9 +152,9 @@ const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
const INDEX_NAME_NEW: Node = Node::Hinted {
|
const INDEX_NAME_NEW: Node = Node::Hinted {
|
||||||
mode: NEW_NAME_HINT,
|
mode: NEW_NAME_HINT,
|
||||||
@@ -181,10 +181,7 @@ const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table")));
|
|||||||
// drop_table — `drop table <T>`
|
// drop_table — `drop table <T>`
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
const DROP_TABLE_NODES: &[Node] = &[
|
const DROP_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
|
||||||
Node::Word(Word::keyword("table")),
|
|
||||||
TABLE_NAME_EXISTING,
|
|
||||||
];
|
|
||||||
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
|
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
|
||||||
|
|
||||||
// Advanced-mode SQL `DROP TABLE [IF EXISTS] <name> [;]` (ADR-0035 §4,
|
// 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
|
// plus the optional `IF EXISTS` no-op-with-note. The leading concrete
|
||||||
// `table` keyword (not the Optional) keeps the element/dispatch
|
// `table` keyword (not the Optional) keeps the element/dispatch
|
||||||
// matching honest.
|
// matching honest.
|
||||||
static SQL_DROP_IF_EXISTS_NODES: &[Node] =
|
static SQL_DROP_IF_EXISTS_NODES: &[Node] = &[
|
||||||
&[Node::Word(Word::keyword("if")), Node::Word(Word::keyword("exists"))];
|
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));
|
const SQL_DROP_IF_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_DROP_IF_EXISTS_NODES));
|
||||||
static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
|
static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
|
||||||
Node::Word(Word::keyword("table")),
|
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_CHOICES: &[Node] = &[DR_ENDPOINTS, RELATIONSHIP_NAME];
|
||||||
const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES);
|
const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES);
|
||||||
|
|
||||||
const DROP_RELATIONSHIP_NODES: &[Node] = &[
|
const DROP_RELATIONSHIP_NODES: &[Node] = &[Node::Word(Word::keyword("relationship")), DR_SELECTOR];
|
||||||
Node::Word(Word::keyword("relationship")),
|
|
||||||
DR_SELECTOR,
|
|
||||||
];
|
|
||||||
const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES);
|
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_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING];
|
||||||
const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES);
|
const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES);
|
||||||
|
|
||||||
const DROP_INDEX_NODES: &[Node] = &[
|
const DROP_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), DI_SELECTOR];
|
||||||
Node::Word(Word::keyword("index")),
|
|
||||||
DI_SELECTOR,
|
|
||||||
];
|
|
||||||
const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);
|
const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// drop entry — `drop (table|column|relationship|index) ...`
|
// drop entry — `drop (table|column|relationship|index) ...`
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
const DROP_CHOICES: &[Node] =
|
const DROP_CHOICES: &[Node] = &[
|
||||||
&[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX, DROP_CONSTRAINT];
|
DROP_COLUMN,
|
||||||
|
DROP_RELATIONSHIP,
|
||||||
|
DROP_TABLE,
|
||||||
|
DROP_INDEX,
|
||||||
|
DROP_CONSTRAINT,
|
||||||
|
];
|
||||||
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
|
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -450,8 +448,7 @@ const AR_CHILD_COL_LIST: Node = Node::Repeated {
|
|||||||
separator: Some(&Node::Punct(',')),
|
separator: Some(&Node::Punct(',')),
|
||||||
min: 1,
|
min: 1,
|
||||||
};
|
};
|
||||||
const AR_CHILD_COLS_PAREN_NODES: &[Node] =
|
const AR_CHILD_COLS_PAREN_NODES: &[Node] = &[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
|
||||||
&[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_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_CHOICES: &[Node] = &[AR_CHILD_COLS_PAREN, AR_CHILD_COL];
|
||||||
const AR_CHILD_COLS: Node = Node::Choice(AR_CHILD_COLS_CHOICES);
|
const AR_CHILD_COLS: Node = Node::Choice(AR_CHILD_COLS_CHOICES);
|
||||||
@@ -474,18 +471,28 @@ const AR_CHILD_NODES: &[Node] = &[
|
|||||||
];
|
];
|
||||||
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
|
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
|
||||||
|
|
||||||
const AR_AS_NAME_NODES: &[Node] = &[
|
const AR_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), RELATIONSHIP_NAME_NEW];
|
||||||
Node::Word(Word::keyword("as")),
|
|
||||||
RELATIONSHIP_NAME_NEW,
|
|
||||||
];
|
|
||||||
const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES));
|
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"));
|
const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk"));
|
||||||
|
|
||||||
const ADD_RELATIONSHIP_NODES: &[Node] = &[
|
// The `1:n` cardinality marker, tagged as the
|
||||||
|
// `cardinality_one_to_many` clause-concept region (issue #37). A
|
||||||
|
// distinct sub-`Seq` so the concept span covers exactly `1:n`, not
|
||||||
|
// the whole relationship form (which the per-form `hint.cmd.*` block
|
||||||
|
// already serves).
|
||||||
|
const CARDINALITY_1N_NODES: &[Node] = &[
|
||||||
Node::Literal("1"),
|
Node::Literal("1"),
|
||||||
Node::Punct(':'),
|
Node::Punct(':'),
|
||||||
Node::Word(Word::keyword("n")),
|
Node::Word(Word::keyword("n")),
|
||||||
|
];
|
||||||
|
const CARDINALITY_1N: Node = Node::Concept {
|
||||||
|
topic: "cardinality_one_to_many",
|
||||||
|
inner: &Node::Seq(CARDINALITY_1N_NODES),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADD_RELATIONSHIP_NODES: &[Node] = &[
|
||||||
|
CARDINALITY_1N,
|
||||||
Node::Word(Word::keyword("relationship")),
|
Node::Word(Word::keyword("relationship")),
|
||||||
AR_AS_NAME_OPT,
|
AR_AS_NAME_OPT,
|
||||||
Node::Word(Word::keyword("from")),
|
Node::Word(Word::keyword("from")),
|
||||||
@@ -501,10 +508,7 @@ const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES);
|
|||||||
// add_index — `add index [as <name>] on <T> (<col>, …)`
|
// add_index — `add index [as <name>] on <T> (<col>, …)`
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
const AI_AS_NAME_NODES: &[Node] = &[
|
const AI_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), INDEX_NAME_NEW];
|
||||||
Node::Word(Word::keyword("as")),
|
|
||||||
INDEX_NAME_NEW,
|
|
||||||
];
|
|
||||||
const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES));
|
const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES));
|
||||||
|
|
||||||
const ADD_INDEX_NODES: &[Node] = &[
|
const ADD_INDEX_NODES: &[Node] = &[
|
||||||
@@ -537,9 +541,9 @@ const NEW_COLUMN_NAME_IDENT: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
const NEW_COLUMN_NAME: Node = Node::Hinted {
|
const NEW_COLUMN_NAME: Node = Node::Hinted {
|
||||||
mode: NEW_NAME_HINT,
|
mode: NEW_NAME_HINT,
|
||||||
@@ -563,10 +567,7 @@ const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES);
|
|||||||
// ( <type> ) [--force-conversion | --dont-convert]`
|
// ( <type> ) [--force-conversion | --dont-convert]`
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
const CHANGE_FLAG_CHOICES: &[Node] = &[
|
const CHANGE_FLAG_CHOICES: &[Node] = &[Node::Flag("force-conversion"), Node::Flag("dont-convert")];
|
||||||
Node::Flag("force-conversion"),
|
|
||||||
Node::Flag("dont-convert"),
|
|
||||||
];
|
|
||||||
const CHANGE_FLAG_OPT: Node = Node::Repeated {
|
const CHANGE_FLAG_OPT: Node = Node::Repeated {
|
||||||
inner: &Node::Choice(CHANGE_FLAG_CHOICES),
|
inner: &Node::Choice(CHANGE_FLAG_CHOICES),
|
||||||
separator: None,
|
separator: None,
|
||||||
@@ -732,8 +733,7 @@ fn build_add(path: &MatchedPath, _source: &str) -> Result<Command, ValidationErr
|
|||||||
message_key: "parse.error_wrapper",
|
message_key: "parse.error_wrapper",
|
||||||
args: vec![("detail", "unknown type".to_string())],
|
args: vec![("detail", "unknown type".to_string())],
|
||||||
})?;
|
})?;
|
||||||
let (not_null, unique, default, check) =
|
let (not_null, unique, default, check) = collect_column_constraints(path)?;
|
||||||
collect_column_constraints(path)?;
|
|
||||||
Ok(Command::AddColumn {
|
Ok(Command::AddColumn {
|
||||||
table: require_ident(path, "table_name")?,
|
table: require_ident(path, "table_name")?,
|
||||||
column: require_ident(path, "column_name")?,
|
column: require_ident(path, "column_name")?,
|
||||||
@@ -949,7 +949,10 @@ fn build_drop_constraint(path: &MatchedPath, _source: &str) -> Result<Command, V
|
|||||||
} else {
|
} else {
|
||||||
return Err(ValidationError {
|
return Err(ValidationError {
|
||||||
message_key: "parse.error_wrapper",
|
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 {
|
Ok(Command::DropConstraint {
|
||||||
@@ -981,7 +984,8 @@ pub static DROP: CommandNode = CommandNode {
|
|||||||
"parse.usage.drop_relationship",
|
"parse.usage.drop_relationship",
|
||||||
"parse.usage.drop_index",
|
"parse.usage.drop_index",
|
||||||
"parse.usage.drop_constraint",
|
"parse.usage.drop_constraint",
|
||||||
],};
|
],
|
||||||
|
};
|
||||||
|
|
||||||
pub static ADD: CommandNode = CommandNode {
|
pub static ADD: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("add"),
|
entry: Word::keyword("add"),
|
||||||
@@ -1003,7 +1007,8 @@ pub static ADD: CommandNode = CommandNode {
|
|||||||
"parse.usage.add_relationship",
|
"parse.usage.add_relationship",
|
||||||
"parse.usage.add_index",
|
"parse.usage.add_index",
|
||||||
"parse.usage.add_constraint",
|
"parse.usage.add_constraint",
|
||||||
],};
|
],
|
||||||
|
};
|
||||||
|
|
||||||
pub static RENAME: CommandNode = CommandNode {
|
pub static RENAME: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("rename"),
|
entry: Word::keyword("rename"),
|
||||||
@@ -1011,7 +1016,8 @@ pub static RENAME: CommandNode = CommandNode {
|
|||||||
ast_builder: build_rename_column,
|
ast_builder: build_rename_column,
|
||||||
help_id: Some("ddl.rename"),
|
help_id: Some("ddl.rename"),
|
||||||
hint_ids: &["rename_column"],
|
hint_ids: &["rename_column"],
|
||||||
usage_ids: &["parse.usage.rename_column"],};
|
usage_ids: &["parse.usage.rename_column"],
|
||||||
|
};
|
||||||
|
|
||||||
pub static CHANGE: CommandNode = CommandNode {
|
pub static CHANGE: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("change"),
|
entry: Word::keyword("change"),
|
||||||
@@ -1019,7 +1025,8 @@ pub static CHANGE: CommandNode = CommandNode {
|
|||||||
ast_builder: build_change_column,
|
ast_builder: build_change_column,
|
||||||
help_id: Some("ddl.change"),
|
help_id: Some("ddl.change"),
|
||||||
hint_ids: &["change_column"],
|
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>)[, ...]]]`
|
// create_table — `create table <Name> [with pk [<col>(<type>)[, ...]]]`
|
||||||
@@ -1034,9 +1041,9 @@ const COL_NAME_IDENT: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
const COL_NAME: Node = Node::Hinted {
|
const COL_NAME: Node = Node::Hinted {
|
||||||
mode: NEW_NAME_HINT,
|
mode: NEW_NAME_HINT,
|
||||||
@@ -1054,7 +1061,14 @@ const NOT_NULL_NODES: &[Node] = &[
|
|||||||
];
|
];
|
||||||
const NOT_NULL_CONSTRAINT: Node = Node::Seq(NOT_NULL_NODES);
|
const NOT_NULL_CONSTRAINT: Node = Node::Seq(NOT_NULL_NODES);
|
||||||
|
|
||||||
const UNIQUE_CONSTRAINT: Node = Node::Word(Word::keyword("unique"));
|
// Tagged as the `unique` clause-concept region (issue #37). Shared
|
||||||
|
// across `create table … with pk`, `add constraint`, and (as a bare
|
||||||
|
// keyword) `drop constraint`, so the concept surfaces anywhere a
|
||||||
|
// unique constraint is being declared or removed.
|
||||||
|
const UNIQUE_CONSTRAINT: Node = Node::Concept {
|
||||||
|
topic: "unique",
|
||||||
|
inner: &Node::Word(Word::keyword("unique")),
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_CONSTRAINT_NODES: &[Node] = &[
|
const DEFAULT_CONSTRAINT_NODES: &[Node] = &[
|
||||||
Node::Word(Word::keyword("default")),
|
Node::Word(Word::keyword("default")),
|
||||||
@@ -1072,10 +1086,20 @@ const CHECK_CONSTRAINT_NODES: &[Node] = &[
|
|||||||
Node::Subgrammar(&super::expr::OR_EXPR),
|
Node::Subgrammar(&super::expr::OR_EXPR),
|
||||||
Node::Punct(')'),
|
Node::Punct(')'),
|
||||||
];
|
];
|
||||||
const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES);
|
// Tagged as the `check` clause-concept region (issue #37). The span
|
||||||
|
// covers the whole `check ( <expr> )`, so a cursor inside the
|
||||||
|
// predicate still resolves the concept.
|
||||||
|
const CHECK_CONSTRAINT: Node = Node::Concept {
|
||||||
|
topic: "check",
|
||||||
|
inner: &Node::Seq(CHECK_CONSTRAINT_NODES),
|
||||||
|
};
|
||||||
|
|
||||||
const COLUMN_CONSTRAINT_CHOICES: &[Node] =
|
const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[
|
||||||
&[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT, CHECK_CONSTRAINT];
|
NOT_NULL_CONSTRAINT,
|
||||||
|
UNIQUE_CONSTRAINT,
|
||||||
|
DEFAULT_CONSTRAINT,
|
||||||
|
CHECK_CONSTRAINT,
|
||||||
|
];
|
||||||
const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES);
|
const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES);
|
||||||
|
|
||||||
/// Zero-or-more constraints — the suffix after a column's
|
/// Zero-or-more constraints — the suffix after a column's
|
||||||
@@ -1114,8 +1138,7 @@ const DROP_CONSTRAINT_KIND: Node = Node::Choice(DROP_CONSTRAINT_KIND_CHOICES);
|
|||||||
// `writes_table: true` on the table ident (via `TABLE_NAME_
|
// `writes_table: true` on the table ident (via `TABLE_NAME_
|
||||||
// EXISTING`) narrows the `.<column>` slot's completion
|
// EXISTING`) narrows the `.<column>` slot's completion
|
||||||
// candidates to that table's columns.
|
// candidates to that table's columns.
|
||||||
const CONSTRAINT_TARGET_NODES: &[Node] =
|
const CONSTRAINT_TARGET_NODES: &[Node] = &[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
|
||||||
&[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
|
|
||||||
const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES);
|
const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES);
|
||||||
|
|
||||||
const ADD_CONSTRAINT_NODES: &[Node] = &[
|
const ADD_CONSTRAINT_NODES: &[Node] = &[
|
||||||
@@ -1161,11 +1184,20 @@ const SPEC_LIST: Node = Node::Repeated {
|
|||||||
};
|
};
|
||||||
const SPEC_LIST_OPT: Node = Node::Optional(&SPEC_LIST);
|
const SPEC_LIST_OPT: Node = Node::Optional(&SPEC_LIST);
|
||||||
|
|
||||||
const WITH_PK_NODES: &[Node] = &[
|
// The `with pk` keywords, tagged as the `primary_key` clause-concept
|
||||||
|
// region (issue #37). Wrapping just the keywords (not the column
|
||||||
|
// `SPEC_LIST` that follows) keeps the concept crisp: a cursor on
|
||||||
|
// `with pk` teaches primary keys, while a cursor on a column's own
|
||||||
|
// `unique` / `check` constraint resolves to that narrower concept.
|
||||||
|
const WITH_PK_MARKER_NODES: &[Node] = &[
|
||||||
Node::Word(Word::keyword("with")),
|
Node::Word(Word::keyword("with")),
|
||||||
Node::Word(Word::keyword("pk")),
|
Node::Word(Word::keyword("pk")),
|
||||||
SPEC_LIST_OPT,
|
|
||||||
];
|
];
|
||||||
|
const WITH_PK_MARKER: Node = Node::Concept {
|
||||||
|
topic: "primary_key",
|
||||||
|
inner: &Node::Seq(WITH_PK_MARKER_NODES),
|
||||||
|
};
|
||||||
|
const WITH_PK_NODES: &[Node] = &[WITH_PK_MARKER, SPEC_LIST_OPT];
|
||||||
const WITH_PK: Node = Node::Seq(WITH_PK_NODES);
|
const WITH_PK: Node = Node::Seq(WITH_PK_NODES);
|
||||||
const WITH_PK_OPT: Node = Node::Optional(&WITH_PK);
|
const WITH_PK_OPT: Node = Node::Optional(&WITH_PK);
|
||||||
|
|
||||||
@@ -1275,10 +1307,14 @@ fn build_create_table(path: &MatchedPath, _source: &str) -> Result<Command, Vali
|
|||||||
let mut items = path.items.iter().peekable();
|
let mut items = path.items.iter().peekable();
|
||||||
while let Some(item) = items.next() {
|
while let Some(item) = items.next() {
|
||||||
match &item.kind {
|
match &item.kind {
|
||||||
MatchedKind::Ident { role: "col_name", .. } => {
|
MatchedKind::Ident {
|
||||||
|
role: "col_name", ..
|
||||||
|
} => {
|
||||||
pending_name = Some(item.text.clone());
|
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 {
|
let ty = item.text.parse::<Type>().map_err(|_| ValidationError {
|
||||||
message_key: "parse.error_wrapper",
|
message_key: "parse.error_wrapper",
|
||||||
args: vec![("detail", "unknown type".to_string())],
|
args: vec![("detail", "unknown type".to_string())],
|
||||||
@@ -1380,7 +1416,8 @@ pub static CREATE: CommandNode = CommandNode {
|
|||||||
ast_builder: build_create_table,
|
ast_builder: build_create_table,
|
||||||
help_id: Some("ddl.create"),
|
help_id: Some("ddl.create"),
|
||||||
hint_ids: &["create_table"],
|
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>]`
|
// create_m2n — `create m:n relationship from <T1> to <T2> [as <name>]`
|
||||||
@@ -1422,10 +1459,22 @@ const M2N_T2: Node = Node::Ident {
|
|||||||
const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW];
|
const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW];
|
||||||
const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES));
|
const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES));
|
||||||
|
|
||||||
const CREATE_M2N_NODES: &[Node] = &[
|
// The `m:n` cardinality marker, tagged as the
|
||||||
|
// `cardinality_many_to_many` clause-concept region (issue #37).
|
||||||
|
// Mirrors `CARDINALITY_1N` — a distinct sub-`Seq` so the span covers
|
||||||
|
// exactly `m:n`.
|
||||||
|
const CARDINALITY_MN_NODES: &[Node] = &[
|
||||||
Node::Literal("m"),
|
Node::Literal("m"),
|
||||||
Node::Punct(':'),
|
Node::Punct(':'),
|
||||||
Node::Word(Word::keyword("n")),
|
Node::Word(Word::keyword("n")),
|
||||||
|
];
|
||||||
|
const CARDINALITY_MN: Node = Node::Concept {
|
||||||
|
topic: "cardinality_many_to_many",
|
||||||
|
inner: &Node::Seq(CARDINALITY_MN_NODES),
|
||||||
|
};
|
||||||
|
|
||||||
|
const CREATE_M2N_NODES: &[Node] = &[
|
||||||
|
CARDINALITY_MN,
|
||||||
Node::Word(Word::keyword("relationship")),
|
Node::Word(Word::keyword("relationship")),
|
||||||
Node::Word(Word::keyword("from")),
|
Node::Word(Word::keyword("from")),
|
||||||
M2N_T1,
|
M2N_T1,
|
||||||
@@ -1506,11 +1555,15 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
|||||||
while let Some(item) = items.next() {
|
while let Some(item) = items.next() {
|
||||||
match &item.kind {
|
match &item.kind {
|
||||||
// A column name stashes until its type finalises the spec.
|
// 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());
|
pending_name = Some(item.text.clone());
|
||||||
}
|
}
|
||||||
// Single-word type — resolve through the SQL alias map.
|
// 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 {
|
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
|
||||||
message_key: "parse.error_wrapper",
|
message_key: "parse.error_wrapper",
|
||||||
args: vec![("detail", "unknown type".to_string())],
|
args: vec![("detail", "unknown type".to_string())],
|
||||||
@@ -1533,7 +1586,9 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
|||||||
column_open = true;
|
column_open = true;
|
||||||
}
|
}
|
||||||
// A table-level `PRIMARY KEY (col, …)` column reference.
|
// A table-level `PRIMARY KEY (col, …)` column reference.
|
||||||
MatchedKind::Ident { role: "pk_column", .. } => {
|
MatchedKind::Ident {
|
||||||
|
role: "pk_column", ..
|
||||||
|
} => {
|
||||||
primary_key.push(item.text.clone());
|
primary_key.push(item.text.clone());
|
||||||
}
|
}
|
||||||
// `not null` column constraint (only once a column exists;
|
// `not null` column constraint (only once a column exists;
|
||||||
@@ -1557,7 +1612,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
|||||||
let mut cols: Vec<String> = Vec::new();
|
let mut cols: Vec<String> = Vec::new();
|
||||||
while let Some(it) = items.peek() {
|
while let Some(it) = items.peek() {
|
||||||
match &it.kind {
|
match &it.kind {
|
||||||
MatchedKind::Ident { role: "unique_column", .. } => {
|
MatchedKind::Ident {
|
||||||
|
role: "unique_column",
|
||||||
|
..
|
||||||
|
} => {
|
||||||
cols.push(it.text.clone());
|
cols.push(it.text.clone());
|
||||||
items.next();
|
items.next();
|
||||||
}
|
}
|
||||||
@@ -1575,7 +1633,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
|||||||
// column's flag (round-trips via the single-column
|
// column's flag (round-trips via the single-column
|
||||||
// path); composite (or a name not among the
|
// path); composite (or a name not among the
|
||||||
// columns) becomes a constraint.
|
// 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,
|
Some(c) => c.unique = true,
|
||||||
None if !cols.is_empty() => unique_constraints.push(cols),
|
None if !cols.is_empty() => unique_constraints.push(cols),
|
||||||
None => {}
|
None => {}
|
||||||
@@ -1588,16 +1649,17 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
|||||||
// the most recent column) or the table-level clause (whose
|
// the most recent column) or the table-level clause (whose
|
||||||
// `pk_column` idents follow and are collected above).
|
// `pk_column` idents follow and are collected above).
|
||||||
MatchedKind::Word("primary") => {
|
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();
|
items.next();
|
||||||
// Table-level `PRIMARY KEY (…)` is followed by `(`
|
// Table-level `PRIMARY KEY (…)` is followed by `(`
|
||||||
// (then `pk_column` idents, collected above);
|
// (then `pk_column` idents, collected above);
|
||||||
// column-level `PRIMARY KEY` is not, and marks the
|
// column-level `PRIMARY KEY` is not, and marks the
|
||||||
// most-recent column.
|
// most-recent column.
|
||||||
let table_level = matches!(
|
let table_level =
|
||||||
items.peek().map(|i| &i.kind),
|
matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('(')));
|
||||||
Some(MatchedKind::Punct('('))
|
|
||||||
);
|
|
||||||
if !table_level && let Some(last) = columns.last() {
|
if !table_level && let Some(last) = columns.last() {
|
||||||
primary_key.push(last.name.clone());
|
primary_key.push(last.name.clone());
|
||||||
}
|
}
|
||||||
@@ -1647,12 +1709,20 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
|||||||
// Inline FK is single-column (the column it sits on);
|
// Inline FK is single-column (the column it sits on);
|
||||||
// a compound FK uses the table-level form (ADR-0043 D4).
|
// 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());
|
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>)
|
// Table-level `[constraint <name>] foreign key (<col>)
|
||||||
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
|
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
|
||||||
MatchedKind::Word("foreign") => {
|
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`
|
items.next(); // `key`
|
||||||
}
|
}
|
||||||
// `( <child column> [, <child column>]* )` — a compound
|
// `( <child column> [, <child column>]* )` — a compound
|
||||||
@@ -1674,7 +1744,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
|||||||
items.next();
|
items.next();
|
||||||
}
|
}
|
||||||
// `references <parent> …`
|
// `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();
|
items.next();
|
||||||
}
|
}
|
||||||
let fk =
|
let fk =
|
||||||
@@ -1859,13 +1932,19 @@ where
|
|||||||
Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade,
|
Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade,
|
||||||
Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict,
|
Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict,
|
||||||
Some(MatchedKind::Word("set")) => {
|
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();
|
items.next();
|
||||||
}
|
}
|
||||||
ReferentialAction::SetNull
|
ReferentialAction::SetNull
|
||||||
}
|
}
|
||||||
Some(MatchedKind::Word("no")) => {
|
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();
|
items.next();
|
||||||
}
|
}
|
||||||
ReferentialAction::NoAction
|
ReferentialAction::NoAction
|
||||||
@@ -1933,11 +2012,12 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
|
|||||||
// concrete keyword (`unique index` | `index`) — the trap-safe form (the
|
// concrete keyword (`unique index` | `index`) — the trap-safe form (the
|
||||||
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The
|
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The
|
||||||
// builder reads `unique` presence via `contains_word("unique")`.
|
// builder reads `unique` presence via `contains_word("unique")`.
|
||||||
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] =
|
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] = &[
|
||||||
&[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))];
|
Node::Word(Word::keyword("unique")),
|
||||||
|
Node::Word(Word::keyword("index")),
|
||||||
|
];
|
||||||
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
|
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
|
||||||
static SQL_CI_LEAD_CHOICES: &[Node] =
|
static SQL_CI_LEAD_CHOICES: &[Node] = &[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
|
||||||
&[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
|
|
||||||
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);
|
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);
|
||||||
|
|
||||||
static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
|
static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
|
||||||
@@ -2104,8 +2184,7 @@ static AT_RENAME_COLUMN_TAIL_NODES: &[Node] = &[
|
|||||||
NEW_COLUMN_NAME,
|
NEW_COLUMN_NAME,
|
||||||
];
|
];
|
||||||
const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES);
|
const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES);
|
||||||
static AT_RENAME_TABLE_TAIL_NODES: &[Node] =
|
static AT_RENAME_TABLE_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
|
||||||
&[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
|
|
||||||
const AT_RENAME_TABLE_TAIL: Node = Node::Seq(AT_RENAME_TABLE_TAIL_NODES);
|
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];
|
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);
|
const AT_RENAME_TAIL: Node = Node::Choice(AT_RENAME_TAIL_CHOICES);
|
||||||
@@ -2132,8 +2211,10 @@ static AT_AC_TYPE_NODES: &[Node] = &[
|
|||||||
super::sql_create_table::SQL_TYPE,
|
super::sql_create_table::SQL_TYPE,
|
||||||
];
|
];
|
||||||
const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES);
|
const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES);
|
||||||
static AT_AC_NOT_NULL_NODES: &[Node] =
|
static AT_AC_NOT_NULL_NODES: &[Node] = &[
|
||||||
&[Node::Word(Word::keyword("not")), Node::Word(Word::keyword("null"))];
|
Node::Word(Word::keyword("not")),
|
||||||
|
Node::Word(Word::keyword("null")),
|
||||||
|
];
|
||||||
const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES);
|
const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES);
|
||||||
static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[
|
static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[
|
||||||
Node::Word(Word::keyword("data")),
|
Node::Word(Word::keyword("data")),
|
||||||
@@ -2149,8 +2230,7 @@ static AT_AC_SET_TAIL_CHOICES: &[Node] = &[
|
|||||||
const AT_AC_SET_TAIL: Node = Node::Choice(AT_AC_SET_TAIL_CHOICES);
|
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];
|
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);
|
const AT_AC_SET: Node = Node::Seq(AT_AC_SET_NODES);
|
||||||
static AT_AC_DROP_TAIL_CHOICES: &[Node] =
|
static AT_AC_DROP_TAIL_CHOICES: &[Node] = &[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
|
||||||
&[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
|
|
||||||
const AT_AC_DROP_TAIL: Node = Node::Choice(AT_AC_DROP_TAIL_CHOICES);
|
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];
|
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);
|
const AT_AC_DROP: Node = Node::Seq(AT_AC_DROP_NODES);
|
||||||
@@ -2258,10 +2338,14 @@ fn build_alter_add_column_spec(
|
|||||||
let mut items = path.items.iter().peekable();
|
let mut items = path.items.iter().peekable();
|
||||||
while let Some(item) = items.next() {
|
while let Some(item) = items.next() {
|
||||||
match &item.kind {
|
match &item.kind {
|
||||||
MatchedKind::Ident { role: "col_name", .. } => {
|
MatchedKind::Ident {
|
||||||
|
role: "col_name", ..
|
||||||
|
} => {
|
||||||
pending_name = Some(item.text.clone());
|
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 {
|
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
|
||||||
message_key: "parse.error_wrapper",
|
message_key: "parse.error_wrapper",
|
||||||
args: vec![("detail", "unknown type".to_string())],
|
args: vec![("detail", "unknown type".to_string())],
|
||||||
@@ -2280,7 +2364,10 @@ fn build_alter_add_column_spec(
|
|||||||
spec = Some(ColumnSpec::new(name, Type::Real));
|
spec = Some(ColumnSpec::new(name, Type::Real));
|
||||||
}
|
}
|
||||||
MatchedKind::Word("not") => {
|
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();
|
items.next();
|
||||||
if let Some(s) = spec.as_mut() {
|
if let Some(s) = spec.as_mut() {
|
||||||
s.not_null = true;
|
s.not_null = true;
|
||||||
@@ -2326,11 +2413,15 @@ fn build_alter_column_type(path: &MatchedPath) -> Result<AlterTableAction, Valid
|
|||||||
let mut items = path.items.iter().peekable();
|
let mut items = path.items.iter().peekable();
|
||||||
while let Some(item) = items.next() {
|
while let Some(item) = items.next() {
|
||||||
match &item.kind {
|
match &item.kind {
|
||||||
MatchedKind::Ident { role: "col_type", .. } => {
|
MatchedKind::Ident {
|
||||||
ty = Some(Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
|
role: "col_type", ..
|
||||||
|
} => {
|
||||||
|
ty = Some(
|
||||||
|
Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
|
||||||
message_key: "parse.error_wrapper",
|
message_key: "parse.error_wrapper",
|
||||||
args: vec![("detail", "unknown type".to_string())],
|
args: vec![("detail", "unknown type".to_string())],
|
||||||
})?);
|
})?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
MatchedKind::Word("double") => {
|
MatchedKind::Word("double") => {
|
||||||
if matches!(
|
if matches!(
|
||||||
@@ -2379,7 +2470,10 @@ fn build_alter_column_attr(
|
|||||||
message_key: "parse.error_wrapper",
|
message_key: "parse.error_wrapper",
|
||||||
args: vec![("detail", "set default needs a value".to_string())],
|
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 },
|
(false, true) => AlterTableAction::DropColumnDefault { column },
|
||||||
(true, false) => AlterTableAction::SetColumnNotNull { column },
|
(true, false) => AlterTableAction::SetColumnNotNull { column },
|
||||||
@@ -2495,10 +2589,7 @@ fn build_alter_add_table_constraint(
|
|||||||
/// Capture the raw SQL text of an `ADD … CHECK (<expr>)` (ADR-0035 §4g).
|
/// 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
|
/// `sql_expr` is validate-only, so the expression is captured by byte
|
||||||
/// span — the 4a.2 / 4e mechanism.
|
/// span — the 4a.2 / 4e mechanism.
|
||||||
fn capture_table_check_sql(
|
fn capture_table_check_sql(path: &MatchedPath, source: &str) -> Result<String, ValidationError> {
|
||||||
path: &MatchedPath,
|
|
||||||
source: &str,
|
|
||||||
) -> Result<String, ValidationError> {
|
|
||||||
let mut items = path.items.iter().peekable();
|
let mut items = path.items.iter().peekable();
|
||||||
while let Some(item) = items.next() {
|
while let Some(item) = items.next() {
|
||||||
if matches!(item.kind, MatchedKind::Word("check"))
|
if matches!(item.kind, MatchedKind::Word("check"))
|
||||||
@@ -2528,7 +2619,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
|
|||||||
items.next();
|
items.next();
|
||||||
}
|
}
|
||||||
items.next(); // `foreign`
|
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();
|
items.next();
|
||||||
}
|
}
|
||||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
||||||
@@ -2548,7 +2642,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
|
|||||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
|
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
|
||||||
items.next();
|
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();
|
items.next();
|
||||||
}
|
}
|
||||||
// `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form.
|
// `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form.
|
||||||
@@ -2626,7 +2723,10 @@ mod constraint_tests {
|
|||||||
fn an_unconstrained_create_table_still_parses() {
|
fn an_unconstrained_create_table_still_parses() {
|
||||||
let cols = create_columns("create table T with pk id(serial), name(text)");
|
let cols = create_columns("create table T with pk id(serial), name(text)");
|
||||||
assert_eq!(cols.len(), 2);
|
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]
|
#[test]
|
||||||
@@ -2651,7 +2751,9 @@ mod constraint_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn add_column_parses_a_unique_constraint() {
|
fn add_column_parses_a_unique_constraint() {
|
||||||
match parse_command("add column to T: email (text) unique").expect("parse") {
|
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!(unique);
|
||||||
assert!(!not_null);
|
assert!(!not_null);
|
||||||
}
|
}
|
||||||
@@ -2682,9 +2784,7 @@ mod constraint_tests {
|
|||||||
fn check_with_a_parenthesised_sub_expression_parses() {
|
fn check_with_a_parenthesised_sub_expression_parses() {
|
||||||
// The check's own parens plus a nested group — the
|
// The check's own parens plus a nested group — the
|
||||||
// builder's paren-depth scan must pair them correctly.
|
// builder's paren-depth scan must pair them correctly.
|
||||||
let cols = create_columns(
|
let cols = create_columns("create table T with pk n(int) check ((n > 0) or (n < -10))");
|
||||||
"create table T with pk n(int) check ((n > 0) or (n < -10))",
|
|
||||||
);
|
|
||||||
assert!(cols[0].check.is_some());
|
assert!(cols[0].check.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2731,8 +2831,7 @@ mod constraint_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_constraint_check_parses() {
|
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 {
|
Command::AddConstraint {
|
||||||
column, constraint, ..
|
column, constraint, ..
|
||||||
} => {
|
} => {
|
||||||
@@ -2826,7 +2925,10 @@ mod sql_drop_table_tests {
|
|||||||
Command::DropColumn { .. }
|
Command::DropColumn { .. }
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
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"),
|
.expect("parses"),
|
||||||
Command::DropRelationship { .. }
|
Command::DropRelationship { .. }
|
||||||
));
|
));
|
||||||
@@ -2932,7 +3034,13 @@ mod sql_create_index_tests {
|
|||||||
columns,
|
columns,
|
||||||
unique,
|
unique,
|
||||||
if_not_exists,
|
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:?}"),
|
other => panic!("expected SqlCreateIndex, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3134,7 +3242,9 @@ mod sql_alter_table_tests {
|
|||||||
// The target slot carries the `reject_internal_table` validator
|
// The target slot carries the `reject_internal_table` validator
|
||||||
// (mirroring CREATE TABLE), so an `__rdbms_*` target is refused
|
// (mirroring CREATE TABLE), so an `__rdbms_*` target is refused
|
||||||
// before submit — engine-neutral, not a raw engine error.
|
// 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]
|
#[test]
|
||||||
@@ -3213,7 +3323,10 @@ mod sql_alter_table_tests {
|
|||||||
// alias map still applies through the synonym
|
// alias map still applies through the synonym
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
alter("alter table T alter column n set data type double precision").1,
|
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 +3351,10 @@ mod sql_alter_table_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn alter_column_set_default_captures_raw_expr() {
|
fn alter_column_set_default_captures_raw_expr() {
|
||||||
match alter("alter table T alter column qty set default 0").1 {
|
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!(column, "qty");
|
||||||
assert_eq!(default_sql, "0");
|
assert_eq!(default_sql, "0");
|
||||||
}
|
}
|
||||||
@@ -3317,7 +3433,9 @@ mod sql_alter_table_tests {
|
|||||||
match alter("alter table T add check (a < b)").1 {
|
match alter("alter table T add check (a < b)").1 {
|
||||||
AlterTableAction::AddTableConstraint { name, constraint } => {
|
AlterTableAction::AddTableConstraint { name, constraint } => {
|
||||||
assert_eq!(name, None);
|
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:?}"),
|
other => panic!("expected AddTableConstraint/Check, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -3335,7 +3453,9 @@ mod sql_alter_table_tests {
|
|||||||
match alter("alter table T add unique (a, b)").1 {
|
match alter("alter table T add unique (a, b)").1 {
|
||||||
AlterTableAction::AddTableConstraint { name, constraint } => {
|
AlterTableAction::AddTableConstraint { name, constraint } => {
|
||||||
assert_eq!(name, None);
|
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:?}"),
|
other => panic!("expected AddTableConstraint/Unique, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -3352,7 +3472,9 @@ mod sql_alter_table_tests {
|
|||||||
)
|
)
|
||||||
.expect_err("a named UNIQUE constraint is refused");
|
.expect_err("a named UNIQUE constraint is refused");
|
||||||
assert!(
|
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}"
|
"expected the builder's named-UNIQUE refusal, got: {err}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3364,7 +3486,9 @@ mod sql_alter_table_tests {
|
|||||||
let err = parse_command_in_mode("alter table T add primary key (id)", Mode::Advanced)
|
let err = parse_command_in_mode("alter table T add primary key (id)", Mode::Advanced)
|
||||||
.expect_err("ADD PRIMARY KEY is refused");
|
.expect_err("ADD PRIMARY KEY is refused");
|
||||||
assert!(
|
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}"
|
"expected the builder's ADD-PRIMARY-KEY refusal, got: {err}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3392,7 +3516,10 @@ mod sql_alter_table_tests {
|
|||||||
assert_eq!(name.as_deref(), Some("fk_p"));
|
assert_eq!(name.as_deref(), Some("fk_p"));
|
||||||
match *constraint {
|
match *constraint {
|
||||||
TableConstraint::ForeignKey(fk) => {
|
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:?}"),
|
other => panic!("expected ForeignKey, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-35
@@ -79,9 +79,9 @@ const EXPR_COLUMN: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: true,
|
writes_column: true,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Operand alternatives. The literal keywords (`null` / `true`
|
/// 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
|
// the leak is per distinct column (the walker
|
||||||
// memoizes `DynamicSubgrammar` resolution on
|
// memoizes `DynamicSubgrammar` resolution on
|
||||||
// `current_column`), not per keystroke.
|
// `current_column`), not per keystroke.
|
||||||
let leaked: &'static str =
|
let leaked: &'static str = Box::leak(col.name.clone().into_boxed_str());
|
||||||
Box::leak(col.name.clone().into_boxed_str());
|
|
||||||
Node::TypedValueSlot {
|
Node::TypedValueSlot {
|
||||||
ty: col.user_type,
|
ty: col.user_type,
|
||||||
column_name: Some(leaked),
|
column_name: Some(leaked),
|
||||||
@@ -260,10 +259,8 @@ static PAREN_GROUP_NODES: &[Node] = &[
|
|||||||
Node::Subgrammar(&OR_EXPR),
|
Node::Subgrammar(&OR_EXPR),
|
||||||
Node::Punct(')'),
|
Node::Punct(')'),
|
||||||
];
|
];
|
||||||
static BOOL_PRIMARY_CHOICES: &[Node] = &[
|
static BOOL_PRIMARY_CHOICES: &[Node] =
|
||||||
Node::Seq(PAREN_GROUP_NODES),
|
&[Node::Seq(PAREN_GROUP_NODES), Node::Subgrammar(&PREDICATE)];
|
||||||
Node::Subgrammar(&PREDICATE),
|
|
||||||
];
|
|
||||||
static BOOL_PRIMARY: Node = Node::Choice(BOOL_PRIMARY_CHOICES);
|
static BOOL_PRIMARY: Node = Node::Choice(BOOL_PRIMARY_CHOICES);
|
||||||
|
|
||||||
/// `not_expr := NOT not_expr | bool_primary`.
|
/// `not_expr := NOT not_expr | bool_primary`.
|
||||||
@@ -271,10 +268,7 @@ static NOT_FORM_NODES: &[Node] = &[
|
|||||||
Node::Word(Word::keyword("not")),
|
Node::Word(Word::keyword("not")),
|
||||||
Node::Subgrammar(&NOT_EXPR),
|
Node::Subgrammar(&NOT_EXPR),
|
||||||
];
|
];
|
||||||
static NOT_EXPR_CHOICES: &[Node] = &[
|
static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&BOOL_PRIMARY)];
|
||||||
Node::Seq(NOT_FORM_NODES),
|
|
||||||
Node::Subgrammar(&BOOL_PRIMARY),
|
|
||||||
];
|
|
||||||
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
|
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
|
||||||
|
|
||||||
/// `and_expr := not_expr ( AND not_expr )*`.
|
/// `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
|
/// `or_expr := and_expr ( OR and_expr )*` — the fragment entry
|
||||||
/// point. `update` / `delete` / `show data` reference this
|
/// point. `update` / `delete` / `show data` reference this
|
||||||
/// through `Node::Subgrammar(&OR_EXPR)`.
|
/// through `Node::Subgrammar(&OR_EXPR)`.
|
||||||
static OR_TAIL_NODES: &[Node] = &[
|
static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
|
||||||
Node::Word(Word::keyword("or")),
|
|
||||||
Node::Subgrammar(&AND_EXPR),
|
|
||||||
];
|
|
||||||
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
|
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
|
||||||
static OR_EXPR_NODES: &[Node] = &[
|
static OR_EXPR_NODES: &[Node] = &[
|
||||||
Node::Subgrammar(&AND_EXPR),
|
Node::Subgrammar(&AND_EXPR),
|
||||||
@@ -534,18 +525,18 @@ impl<'a> ExprParser<'a> {
|
|||||||
let span = item.span;
|
let span = item.span;
|
||||||
let literal = |value: Value| Operand::Literal { value, span };
|
let literal = |value: Value| Operand::Literal { value, span };
|
||||||
match &item.kind {
|
match &item.kind {
|
||||||
MatchedKind::Ident { role: "expr_column", .. } => {
|
MatchedKind::Ident {
|
||||||
Ok(Operand::Column { name: item.text.clone(), span })
|
role: "expr_column",
|
||||||
}
|
..
|
||||||
|
} => Ok(Operand::Column {
|
||||||
|
name: item.text.clone(),
|
||||||
|
span,
|
||||||
|
}),
|
||||||
MatchedKind::Word("null") => Ok(literal(Value::Null)),
|
MatchedKind::Word("null") => Ok(literal(Value::Null)),
|
||||||
MatchedKind::Word("true") => Ok(literal(Value::Bool(true))),
|
MatchedKind::Word("true") => Ok(literal(Value::Bool(true))),
|
||||||
MatchedKind::Word("false") => Ok(literal(Value::Bool(false))),
|
MatchedKind::Word("false") => Ok(literal(Value::Bool(false))),
|
||||||
MatchedKind::NumberLit => {
|
MatchedKind::NumberLit => Ok(literal(Value::Number(item.text.clone()))),
|
||||||
Ok(literal(Value::Number(item.text.clone())))
|
MatchedKind::StringLit => Ok(literal(Value::Text(item.text.clone()))),
|
||||||
}
|
|
||||||
MatchedKind::StringLit => {
|
|
||||||
Ok(literal(Value::Text(item.text.clone())))
|
|
||||||
}
|
|
||||||
_ => Err(drift_error("expected a column or literal operand")),
|
_ => Err(drift_error("expected a column or literal operand")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -591,8 +582,7 @@ mod tests {
|
|||||||
let mut ctx = WalkContext::new();
|
let mut ctx = WalkContext::new();
|
||||||
let mut path = MatchedPath::new();
|
let mut path = MatchedPath::new();
|
||||||
let mut per_byte = Vec::new();
|
let mut per_byte = Vec::new();
|
||||||
let result =
|
let result = walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
|
||||||
walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
|
|
||||||
match result {
|
match result {
|
||||||
NodeWalkResult::Matched { end, .. } => {
|
NodeWalkResult::Matched { end, .. } => {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -730,8 +720,7 @@ mod tests {
|
|||||||
negated: false,
|
negated: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
let Expr::Predicate(Predicate::Like { negated, .. }) =
|
let Expr::Predicate(Predicate::Like { negated, .. }) = parse_expr("Name not like 'A%'")
|
||||||
parse_expr("Name not like 'A%'")
|
|
||||||
else {
|
else {
|
||||||
panic!("expected a negated Like");
|
panic!("expected a negated Like");
|
||||||
};
|
};
|
||||||
@@ -794,16 +783,16 @@ mod tests {
|
|||||||
fn nested_parentheses_round_trip() {
|
fn nested_parentheses_round_trip() {
|
||||||
// Exercises the Subgrammar recursion a few levels deep.
|
// Exercises the Subgrammar recursion a few levels deep.
|
||||||
let expr = parse_expr("((a = 1 and b = 2) or (c = 3))");
|
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]
|
#[test]
|
||||||
fn case_insensitive_keywords() {
|
fn case_insensitive_keywords() {
|
||||||
// Keywords fold case; the built tree is identical.
|
// Keywords fold case; the built tree is identical.
|
||||||
assert_eq!(
|
assert_eq!(parse_expr("a = 1 AND b = 2"), parse_expr("a = 1 and b = 2"),);
|
||||||
parse_expr("a = 1 AND b = 2"),
|
|
||||||
parse_expr("a = 1 and b = 2"),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_expr("Email IS NOT NULL"),
|
parse_expr("Email IS NOT NULL"),
|
||||||
parse_expr("Email is not null"),
|
parse_expr("Email is not null"),
|
||||||
|
|||||||
+288
-30
@@ -27,9 +27,9 @@ pub mod data;
|
|||||||
pub mod ddl;
|
pub mod ddl;
|
||||||
pub mod expr;
|
pub mod expr;
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
pub mod sql_expr;
|
|
||||||
pub mod sql_create_table;
|
pub mod sql_create_table;
|
||||||
pub mod sql_delete;
|
pub mod sql_delete;
|
||||||
|
pub mod sql_expr;
|
||||||
pub mod sql_insert;
|
pub mod sql_insert;
|
||||||
pub mod sql_select;
|
pub mod sql_select;
|
||||||
pub mod sql_update;
|
pub mod sql_update;
|
||||||
@@ -328,9 +328,7 @@ pub enum Node {
|
|||||||
/// A number literal. The optional `validator` runs against
|
/// A number literal. The optional `validator` runs against
|
||||||
/// the matched text (used by Phase D value slots to enforce
|
/// the matched text (used by Phase D value slots to enforce
|
||||||
/// per-type integer/decimal rules).
|
/// per-type integer/decimal rules).
|
||||||
NumberLit {
|
NumberLit { validator: Option<NumberValidator> },
|
||||||
validator: Option<NumberValidator>,
|
|
||||||
},
|
|
||||||
/// A literal byte sequence at this position — matches
|
/// A literal byte sequence at this position — matches
|
||||||
/// bytes verbatim (whitespace-skipped) with a lookahead so
|
/// bytes verbatim (whitespace-skipped) with a lookahead so
|
||||||
/// `1` doesn't half-match `12` and `n` doesn't half-match
|
/// `1` doesn't half-match `12` and `n` doesn't half-match
|
||||||
@@ -478,6 +476,26 @@ pub enum Node {
|
|||||||
mode: HintMode,
|
mode: HintMode,
|
||||||
inner: &'static Self,
|
inner: &'static Self,
|
||||||
},
|
},
|
||||||
|
/// Annotates `inner` as a recognized *clause-concept* region
|
||||||
|
/// (issue #37 / ADR clause-concept-hints, D1). Transparent to
|
||||||
|
/// matching, highlighting and the expected-set — it walks
|
||||||
|
/// `inner` and returns its result verbatim — but as a side
|
||||||
|
/// effect the walker records the byte span the clause covered
|
||||||
|
/// into `WalkContext::concept_spans`, tagged with `topic` (a
|
||||||
|
/// `hint.concept.<topic>` catalogue key). The F1 hint surface
|
||||||
|
/// reads those spans to layer a clause-level teaching block on
|
||||||
|
/// top of the per-form `hint.cmd.*` block when the cursor sits
|
||||||
|
/// inside the clause.
|
||||||
|
///
|
||||||
|
/// Sibling of `Hinted`, but where `Hinted` records a single
|
||||||
|
/// *pending* slot mode (cleared on the next match, so it marks
|
||||||
|
/// the slot the cursor is *about to fill*), `Concept` appends a
|
||||||
|
/// durable span that survives the clause being fully matched —
|
||||||
|
/// the "cursor anywhere inside the clause" semantics #37 wants.
|
||||||
|
Concept {
|
||||||
|
topic: &'static str,
|
||||||
|
inner: &'static Self,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which mode group a registered command belongs to (ADR-0030
|
/// Which mode group a registered command belongs to (ADR-0030
|
||||||
@@ -539,8 +557,11 @@ pub struct CommandNode {
|
|||||||
/// block). `hint_key_for_input_in_mode` disambiguates by the form
|
/// block). `hint_key_for_input_in_mode` disambiguates by the form
|
||||||
/// word, reusing `usage_key_for_input_in_mode`'s logic. Empty
|
/// word, reusing `usage_key_for_input_in_mode`'s logic. Empty
|
||||||
/// until a form's tier-3 block is authored (the surface falls back
|
/// until a form's tier-3 block is authored (the surface falls back
|
||||||
/// to tier-2 ambient/error text). Distinct from `help_id` (which is
|
/// to tier-2 ambient/error text). Parallel to `help_id` but
|
||||||
/// `None` on advanced-SQL forms purely to dedup the `help` list).
|
/// finer-grained: every form (simple and advanced) carries a
|
||||||
|
/// `hint_id`, whereas the `help <topic>` view groups forms by entry
|
||||||
|
/// word (so a shared-entry simple + SQL pair both surface under e.g.
|
||||||
|
/// `help insert`).
|
||||||
pub hint_ids: &'static [&'static str],
|
pub hint_ids: &'static [&'static str],
|
||||||
/// Catalog keys under `parse.usage.*` to render in the
|
/// Catalog keys under `parse.usage.*` to render in the
|
||||||
/// "usage:" block when a parse error fires for this command
|
/// "usage:" block when a parse error fires for this command
|
||||||
@@ -701,7 +722,11 @@ fn selected_nodes_for_input_in_mode(
|
|||||||
.filter(|(_, _, c)| *c == CommandCategory::Simple)
|
.filter(|(_, _, c)| *c == CommandCategory::Simple)
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
if selected.is_empty() { candidates } else { selected }
|
if selected.is_empty() {
|
||||||
|
candidates
|
||||||
|
} else {
|
||||||
|
selected
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The single usage template most relevant to `source`, when
|
/// The single usage template most relevant to `source`, when
|
||||||
@@ -724,10 +749,7 @@ pub fn usage_key_for_input(source: &str) -> Option<&'static str> {
|
|||||||
/// disambiguates the single most-relevant usage key from the
|
/// disambiguates the single most-relevant usage key from the
|
||||||
/// mode-selected key set.
|
/// mode-selected key set.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn usage_key_for_input_in_mode(
|
pub fn usage_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
|
||||||
source: &str,
|
|
||||||
mode: crate::mode::Mode,
|
|
||||||
) -> Option<&'static str> {
|
|
||||||
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
|
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
|
||||||
pick_form_key(source, &keys)
|
pick_form_key(source, &keys)
|
||||||
}
|
}
|
||||||
@@ -755,7 +777,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`
|
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
|
||||||
// — a letter, so the digit branch misses it; its key ends `…m2n`.
|
// — 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"));
|
return keys.iter().copied().find(|k| k.ends_with("m2n"));
|
||||||
}
|
}
|
||||||
// Otherwise the form word is an identifier — `column`, `index`,
|
// Otherwise the form word is an identifier — `column`, `index`,
|
||||||
@@ -770,8 +795,7 @@ fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
|
|||||||
/// which read the same data through the legacy `usage::REGISTRY`.
|
/// which read the same data through the legacy `usage::REGISTRY`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn entry_words_alphabetised() -> Vec<&'static str> {
|
pub fn entry_words_alphabetised() -> Vec<&'static str> {
|
||||||
let mut words: Vec<&'static str> =
|
let mut words: Vec<&'static str> = REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
|
||||||
REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
|
|
||||||
words.sort_unstable();
|
words.sort_unstable();
|
||||||
words.dedup();
|
words.dedup();
|
||||||
words
|
words
|
||||||
@@ -791,6 +815,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
|||||||
(&app::QUIT, CommandCategory::Simple),
|
(&app::QUIT, CommandCategory::Simple),
|
||||||
(&app::HELP, CommandCategory::Simple),
|
(&app::HELP, CommandCategory::Simple),
|
||||||
(&app::HINT, CommandCategory::Simple),
|
(&app::HINT, CommandCategory::Simple),
|
||||||
|
(&app::VERSION, CommandCategory::Simple),
|
||||||
(&app::REBUILD, CommandCategory::Simple),
|
(&app::REBUILD, CommandCategory::Simple),
|
||||||
(&app::SAVE, CommandCategory::Simple),
|
(&app::SAVE, CommandCategory::Simple),
|
||||||
(&app::NEW, CommandCategory::Simple),
|
(&app::NEW, CommandCategory::Simple),
|
||||||
@@ -904,9 +929,7 @@ pub fn command_for_entry_word(word: &str) -> Option<(usize, &'static CommandNode
|
|||||||
/// returns its `Simple` DSL node and `Advanced` SQL node. The
|
/// returns its `Simple` DSL node and `Advanced` SQL node. The
|
||||||
/// dispatcher picks among them by the active input mode.
|
/// dispatcher picks among them by the active input mode.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn commands_for_entry_word(
|
pub fn commands_for_entry_word(word: &str) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
|
||||||
word: &str,
|
|
||||||
) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
|
|
||||||
REGISTRY
|
REGISTRY
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -1009,7 +1032,246 @@ mod hint_key_tests {
|
|||||||
];
|
];
|
||||||
for c in classes {
|
for c in classes {
|
||||||
let key = format!("hint.err.{c}.what");
|
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}`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Semantic-verification guard (handoff-71): every `hint.cmd.<form>`
|
||||||
|
/// **example** must parse in the mode the form is taught for. This
|
||||||
|
/// backstops the bug class found in the H2 corpus pass — an example
|
||||||
|
/// that drifts out of the real grammar (a typo, a removed clause, or
|
||||||
|
/// an argument the command never accepted, e.g. an inline name on
|
||||||
|
/// `save as` which opens a modal instead). It cannot police the
|
||||||
|
/// *semantics* of an example that happens to parse (that is the
|
||||||
|
/// manual pass), but it locks the syntactic floor so future edits
|
||||||
|
/// can't ship an unparseable teaching line.
|
||||||
|
///
|
||||||
|
/// The mode per form mirrors `hint_key_for_input_in_mode`: the
|
||||||
|
/// advanced-SQL forms are taught in advanced mode; everything else
|
||||||
|
/// (DSL + app commands) in simple mode.
|
||||||
|
#[test]
|
||||||
|
fn every_cmd_hint_example_parses_in_its_mode() {
|
||||||
|
use crate::dsl::parser::parse_command_in_mode;
|
||||||
|
use crate::mode::Mode;
|
||||||
|
|
||||||
|
// Advanced-mode forms — the SQL surface (ADR-0030–0039). Every
|
||||||
|
// other form (DSL + app commands) is taught in simple mode. This
|
||||||
|
// mirrors the mode split `hint_key_for_input_in_mode` resolves.
|
||||||
|
const ADVANCED: &[&str] = &[
|
||||||
|
"sql_create_table",
|
||||||
|
"sql_alter_table",
|
||||||
|
"sql_create_index",
|
||||||
|
"sql_drop_index",
|
||||||
|
"sql_drop_table",
|
||||||
|
"sql_insert",
|
||||||
|
"sql_update",
|
||||||
|
"sql_delete",
|
||||||
|
"select",
|
||||||
|
"with",
|
||||||
|
"explain_sql",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Iterate the *catalog* (the corpus is the source of truth), not the
|
||||||
|
// REGISTRY: this reaches every `hint.cmd.<id>` block including any
|
||||||
|
// not owned by a command node, so an orphaned or mis-keyed example
|
||||||
|
// can't slip past the guard.
|
||||||
|
let cat = crate::friendly::catalog();
|
||||||
|
let mut checked = 0usize;
|
||||||
|
for key in cat.keys() {
|
||||||
|
let Some(id) = key
|
||||||
|
.strip_prefix("hint.cmd.")
|
||||||
|
.and_then(|rest| rest.strip_suffix(".example"))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let example = cat.get(key).expect("key came from the catalog");
|
||||||
|
let mode = if ADVANCED.contains(&id) {
|
||||||
|
Mode::Advanced
|
||||||
|
} else {
|
||||||
|
Mode::Simple
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
parse_command_in_mode(example, mode).is_ok(),
|
||||||
|
"hint.cmd.{id}.example does not parse in {mode:?} mode: {example:?}",
|
||||||
|
);
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
// Floor guard: the corpus had 49 command forms at the time of
|
||||||
|
// writing (ADR-0053). If this drops, a block (and its example
|
||||||
|
// coverage) silently vanished.
|
||||||
|
assert!(
|
||||||
|
checked >= 49,
|
||||||
|
"expected at least 49 hint.cmd.* examples, checked {checked}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clause-concept comprehensiveness (issue #37 / clause-concept-
|
||||||
|
// hints D7) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A concept topic's mode reachability — drives both the example
|
||||||
|
/// shape (both-mode topics carry `example.simple`+`.advanced`;
|
||||||
|
/// single-mode topics a plain `example`) and the mode each example
|
||||||
|
/// must parse in.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
enum ConceptModes {
|
||||||
|
Both,
|
||||||
|
SimpleOnly,
|
||||||
|
AdvancedOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every clause-concept topic + its mode coverage (D4). Source of
|
||||||
|
/// truth the gates below cross-check against the grammar wrappers
|
||||||
|
/// and the catalogue, so a wrapper or block can't drift out of sync.
|
||||||
|
const CONCEPT_TOPICS: &[(&str, ConceptModes)] = &[
|
||||||
|
("referential_actions", ConceptModes::Both),
|
||||||
|
("cardinality_one_to_many", ConceptModes::SimpleOnly),
|
||||||
|
("cardinality_many_to_many", ConceptModes::SimpleOnly),
|
||||||
|
("primary_key", ConceptModes::Both),
|
||||||
|
("unique", ConceptModes::Both),
|
||||||
|
("check", ConceptModes::Both),
|
||||||
|
("foreign_key", ConceptModes::AdvancedOnly),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Recursively collect every `Node::Concept` topic reachable from a
|
||||||
|
/// shape, following the static-child edges **and** `Subgrammar`/
|
||||||
|
/// `ScopedSubgrammar` references (the SQL `create table` body — and
|
||||||
|
/// thus the advanced `foreign_key` / constraint wrappers — hangs off
|
||||||
|
/// the command shape through a `Subgrammar`). Cycles in the
|
||||||
|
/// expression grammar are broken by a visited-by-address set; every
|
||||||
|
/// `Node` lives in static memory, so its address is a stable
|
||||||
|
/// identity. `DynamicSubgrammar` / `Lookahead` build nodes at walk
|
||||||
|
/// time and can't be traversed statically — no concept wrapper hides
|
||||||
|
/// behind one (asserted indirectly: every declared topic is reached).
|
||||||
|
fn collect_concept_topics(
|
||||||
|
node: &super::Node,
|
||||||
|
visited: &mut std::collections::BTreeSet<usize>,
|
||||||
|
out: &mut std::collections::BTreeSet<&'static str>,
|
||||||
|
) {
|
||||||
|
use super::Node;
|
||||||
|
let addr = std::ptr::from_ref(node) as usize;
|
||||||
|
if !visited.insert(addr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match node {
|
||||||
|
Node::Concept { topic, inner } => {
|
||||||
|
out.insert(topic);
|
||||||
|
collect_concept_topics(inner, visited, out);
|
||||||
|
}
|
||||||
|
Node::Seq(children) | Node::Choice(children) => {
|
||||||
|
for c in *children {
|
||||||
|
collect_concept_topics(c, visited, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Node::Optional(inner)
|
||||||
|
| Node::Repeated { inner, .. }
|
||||||
|
| Node::Hinted { inner, .. }
|
||||||
|
| Node::TypedValueSlot { inner, .. }
|
||||||
|
| Node::Subgrammar(inner)
|
||||||
|
| Node::ScopedSubgrammar(inner) => {
|
||||||
|
collect_concept_topics(inner, visited, out);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gate: the set of `Node::Concept` topics wired into the grammar
|
||||||
|
/// exactly matches `CONCEPT_TOPICS` — neither a wrapper without a
|
||||||
|
/// declared topic, nor a declared topic without a wrapper.
|
||||||
|
#[test]
|
||||||
|
fn concept_wrappers_match_declared_topics() {
|
||||||
|
let mut found = std::collections::BTreeSet::new();
|
||||||
|
let mut visited = std::collections::BTreeSet::new();
|
||||||
|
for (node, _category) in super::REGISTRY {
|
||||||
|
collect_concept_topics(&node.shape, &mut visited, &mut found);
|
||||||
|
}
|
||||||
|
let declared: std::collections::BTreeSet<&str> =
|
||||||
|
CONCEPT_TOPICS.iter().map(|(t, _)| *t).collect();
|
||||||
|
for t in &found {
|
||||||
|
assert!(
|
||||||
|
declared.contains(t),
|
||||||
|
"grammar has Node::Concept topic {t:?} missing from CONCEPT_TOPICS",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (t, _) in CONCEPT_TOPICS {
|
||||||
|
assert!(
|
||||||
|
found.contains(t),
|
||||||
|
"CONCEPT_TOPICS lists {t:?} but no Node::Concept wrapper reaches it",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gate: every topic resolves to a `hint.concept.<topic>` block with
|
||||||
|
/// `what` + `concept`, and the example shape matches its mode
|
||||||
|
/// coverage (mode-keyed for Both, plain for single-mode). `keys.rs`
|
||||||
|
/// checks referenced keys resolve; this checks every topic *has* a
|
||||||
|
/// block of the right shape.
|
||||||
|
#[test]
|
||||||
|
fn every_concept_topic_has_a_block() {
|
||||||
|
let cat = crate::friendly::catalog();
|
||||||
|
for (topic, modes) in CONCEPT_TOPICS {
|
||||||
|
for part in ["what", "concept"] {
|
||||||
|
assert!(
|
||||||
|
cat.get(&format!("hint.concept.{topic}.{part}")).is_some(),
|
||||||
|
"missing hint.concept.{topic}.{part}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let simple = cat.get(&format!("hint.concept.{topic}.example.simple"));
|
||||||
|
let advanced = cat.get(&format!("hint.concept.{topic}.example.advanced"));
|
||||||
|
let plain = cat.get(&format!("hint.concept.{topic}.example"));
|
||||||
|
match modes {
|
||||||
|
ConceptModes::Both => {
|
||||||
|
assert!(
|
||||||
|
simple.is_some() && advanced.is_some() && plain.is_none(),
|
||||||
|
"{topic} is Both: needs example.simple + example.advanced, no plain example"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ConceptModes::SimpleOnly | ConceptModes::AdvancedOnly => {
|
||||||
|
assert!(
|
||||||
|
plain.is_some() && simple.is_none() && advanced.is_none(),
|
||||||
|
"{topic} is single-mode: needs a plain example, no mode-keyed variants"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Semantic guard (mirrors `every_cmd_hint_example_parses_in_its_mode`):
|
||||||
|
/// every clause-concept example parses in the mode it is taught for,
|
||||||
|
/// so an example can't drift out of the real grammar.
|
||||||
|
#[test]
|
||||||
|
fn every_concept_example_parses_in_its_mode() {
|
||||||
|
use crate::dsl::parser::parse_command_in_mode;
|
||||||
|
use crate::mode::Mode;
|
||||||
|
let cat = crate::friendly::catalog();
|
||||||
|
let parses = |key: &str, mode: Mode| {
|
||||||
|
let example = cat.get(key).unwrap_or_else(|| panic!("missing {key}"));
|
||||||
|
assert!(
|
||||||
|
parse_command_in_mode(example, mode).is_ok(),
|
||||||
|
"{key} does not parse in {mode:?} mode: {example:?}",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
for (topic, modes) in CONCEPT_TOPICS {
|
||||||
|
match modes {
|
||||||
|
ConceptModes::Both => {
|
||||||
|
parses(
|
||||||
|
&format!("hint.concept.{topic}.example.simple"),
|
||||||
|
Mode::Simple,
|
||||||
|
);
|
||||||
|
parses(
|
||||||
|
&format!("hint.concept.{topic}.example.advanced"),
|
||||||
|
Mode::Advanced,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ConceptModes::SimpleOnly => {
|
||||||
|
parses(&format!("hint.concept.{topic}.example"), Mode::Simple);
|
||||||
|
}
|
||||||
|
ConceptModes::AdvancedOnly => {
|
||||||
|
parses(&format!("hint.concept.{topic}.example"), Mode::Advanced);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1028,10 +1290,7 @@ mod usage_key_tests {
|
|||||||
let cases = [
|
let cases = [
|
||||||
("add column to T: c (int)", "parse.usage.add_column"),
|
("add column to T: c (int)", "parse.usage.add_column"),
|
||||||
("add index on T (c)", "parse.usage.add_index"),
|
("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",
|
"drop constraint check from T.c",
|
||||||
"parse.usage.drop_constraint",
|
"parse.usage.drop_constraint",
|
||||||
@@ -1048,10 +1307,7 @@ mod usage_key_tests {
|
|||||||
("drop table T", "parse.usage.drop_table"),
|
("drop table T", "parse.usage.drop_table"),
|
||||||
("drop column from table T: c", "parse.usage.drop_column"),
|
("drop column from table T: c", "parse.usage.drop_column"),
|
||||||
("drop index i", "parse.usage.drop_index"),
|
("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 data T", "parse.usage.show_data"),
|
||||||
("show table T", "parse.usage.show_table"),
|
("show table T", "parse.usage.show_table"),
|
||||||
// `create` is multi-form (table vs m:n, ADR-0045): each typed
|
// `create` is multi-form (table vs m:n, ADR-0045): each typed
|
||||||
@@ -1090,10 +1346,12 @@ mod usage_key_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn no_two_registered_commands_share_a_help_id() {
|
fn no_two_registered_commands_share_a_help_id() {
|
||||||
// `note_help` emits one help block per `help_id: Some(_)`
|
// `note_help` emits one help block per `help_id: Some(_)`
|
||||||
// with no dedup, so a duplicate help_id prints the same
|
// with no dedup, so a duplicate help_id string prints the same
|
||||||
// command twice in `help`. Shared-entry-word `Advanced`
|
// block twice. Distinct help_ids are fine — a shared-entry-word
|
||||||
// nodes (SQL_INSERT, …, EXPLAIN_SQL) therefore carry
|
// simple + SQL pair (e.g. `data.insert` + `data.sql_insert`,
|
||||||
// `help_id: None` and defer to their `Simple` sibling.
|
// issue #36) each get their own block, grouped under one topic
|
||||||
|
// by `help <topic>` and split across the simple/advanced
|
||||||
|
// sections of the full list.
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
for (command, _category) in super::REGISTRY {
|
for (command, _category) in super::REGISTRY {
|
||||||
if let Some(id) = command.help_id {
|
if let Some(id) = command.help_id {
|
||||||
|
|||||||
+22
-22
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
use crate::completion::TableColumn;
|
use crate::completion::TableColumn;
|
||||||
use crate::dsl::grammar::{
|
use crate::dsl::grammar::{
|
||||||
HighlightClass, HintMode, IdentSource, IdentValidator, Node,
|
HighlightClass, HintMode, IdentSource, IdentValidator, Node, NumberValidator, ValidationError,
|
||||||
NumberValidator, ValidationError, Word,
|
Word,
|
||||||
};
|
};
|
||||||
use crate::dsl::types::Type;
|
use crate::dsl::types::Type;
|
||||||
use crate::dsl::walker::context::WalkContext;
|
use crate::dsl::walker::context::WalkContext;
|
||||||
@@ -32,10 +32,7 @@ pub fn validate_type_name(value: &str) -> Result<(), ValidationError> {
|
|||||||
.join(", ");
|
.join(", ");
|
||||||
Err(ValidationError {
|
Err(ValidationError {
|
||||||
message_key: "parse.custom.unknown_type",
|
message_key: "parse.custom.unknown_type",
|
||||||
args: vec![
|
args: vec![("found", value.to_string()), ("expected", expected)],
|
||||||
("found", value.to_string()),
|
|
||||||
("expected", expected),
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,9 +51,9 @@ pub const TYPE_SLOT: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Qualified column reference (`<Table>.<Column>`) --------------
|
// --- Qualified column reference (`<Table>.<Column>`) --------------
|
||||||
@@ -136,12 +133,25 @@ pub const ON_CLAUSE: Node = Node::Seq(ON_CLAUSE_NODES);
|
|||||||
/// Repeated `on <target> <action>` clauses (0..2 occurrences).
|
/// Repeated `on <target> <action>` clauses (0..2 occurrences).
|
||||||
/// Validation of "specified twice" + max=2 lives in the
|
/// Validation of "specified twice" + max=2 lives in the
|
||||||
/// command's AST builder.
|
/// command's AST builder.
|
||||||
pub const REFERENTIAL_CLAUSES: Node = Node::Repeated {
|
const REFERENTIAL_CLAUSES_INNER: Node = Node::Repeated {
|
||||||
inner: &ON_CLAUSE,
|
inner: &ON_CLAUSE,
|
||||||
separator: None,
|
separator: None,
|
||||||
min: 0,
|
min: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// As [`REFERENTIAL_CLAUSES_INNER`], tagged as the
|
||||||
|
/// `referential_actions` clause-concept region (issue #37).
|
||||||
|
///
|
||||||
|
/// Shared by the simple-mode `add 1:n relationship … on delete/update`
|
||||||
|
/// and the advanced-mode `references … on delete/update` clause, so the
|
||||||
|
/// one wrapper surfaces the concept in both modes (the mode-keyed
|
||||||
|
/// example is chosen by the live mode at F1 time, not by which grammar
|
||||||
|
/// matched).
|
||||||
|
pub const REFERENTIAL_CLAUSES: Node = Node::Concept {
|
||||||
|
topic: "referential_actions",
|
||||||
|
inner: &REFERENTIAL_CLAUSES_INNER,
|
||||||
|
};
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// Typed value slots (ADR-0024 §Phase D, §typed-value-slots)
|
// Typed value slots (ADR-0024 §Phase D, §typed-value-slots)
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -266,11 +276,6 @@ const DATETIME_SLOT: Node = Node::TypedValueSlot {
|
|||||||
column_name: None,
|
column_name: None,
|
||||||
inner: &TEXT_SLOT_INNER,
|
inner: &TEXT_SLOT_INNER,
|
||||||
};
|
};
|
||||||
const BLOB_SLOT: Node = Node::TypedValueSlot {
|
|
||||||
ty: Type::Blob,
|
|
||||||
column_name: None,
|
|
||||||
inner: &TEXT_SLOT_INNER,
|
|
||||||
};
|
|
||||||
// shortid columns store base58 text (ADR-0011 fk_target_type
|
// shortid columns store base58 text (ADR-0011 fk_target_type
|
||||||
// shortid → text); the slot accepts a quoted-text literal or
|
// shortid → text); the slot accepts a quoted-text literal or
|
||||||
// null.
|
// null.
|
||||||
@@ -300,7 +305,6 @@ pub const fn slot_for_type(ty: Type) -> Node {
|
|||||||
Type::Text => TEXT_SLOT,
|
Type::Text => TEXT_SLOT,
|
||||||
Type::Date => DATE_SLOT,
|
Type::Date => DATE_SLOT,
|
||||||
Type::DateTime => DATETIME_SLOT,
|
Type::DateTime => DATETIME_SLOT,
|
||||||
Type::Blob => BLOB_SLOT,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,9 +317,7 @@ const fn slot_inner_for_type(ty: Type) -> &'static Node {
|
|||||||
Type::Real => &REAL_SLOT_INNER,
|
Type::Real => &REAL_SLOT_INNER,
|
||||||
Type::Decimal => &DECIMAL_SLOT_INNER,
|
Type::Decimal => &DECIMAL_SLOT_INNER,
|
||||||
Type::Bool => &BOOL_SLOT_INNER,
|
Type::Bool => &BOOL_SLOT_INNER,
|
||||||
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => {
|
Type::Text | Type::Date | Type::DateTime | Type::ShortId => &TEXT_SLOT_INNER,
|
||||||
&TEXT_SLOT_INNER
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,9 +399,7 @@ pub(crate) const FALLBACK_VALUE_LIST: Node = Node::Repeated {
|
|||||||
/// This is the single source of truth shared by [`column_value_list`]
|
/// This is the single source of truth shared by [`column_value_list`]
|
||||||
/// (which builds the typed slots) and the `data.rs` arity gate (which
|
/// (which builds the typed slots) and the `data.rs` arity gate (which
|
||||||
/// counts them) so the two never disagree (issue #17).
|
/// counts them) so the two never disagree (issue #17).
|
||||||
pub fn insert_target_columns<'c>(
|
pub fn insert_target_columns<'c>(ctx: &'c WalkContext<'_>) -> Option<Vec<&'c TableColumn>> {
|
||||||
ctx: &'c WalkContext<'_>,
|
|
||||||
) -> Option<Vec<&'c TableColumn>> {
|
|
||||||
let table_cols = ctx.current_table_columns.as_ref()?;
|
let table_cols = ctx.current_table_columns.as_ref()?;
|
||||||
if table_cols.is_empty() {
|
if table_cols.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
@@ -204,16 +204,38 @@ static REFERENCES_NODES: &[Node] = &[
|
|||||||
shared::REFERENTIAL_CLAUSES,
|
shared::REFERENTIAL_CLAUSES,
|
||||||
];
|
];
|
||||||
const REFERENCES_CLAUSE: Node = Node::Seq(REFERENCES_NODES);
|
const REFERENCES_CLAUSE: Node = Node::Seq(REFERENCES_NODES);
|
||||||
|
// The `foreign_key` clause-concept region (issue #37) — wraps the
|
||||||
|
// whole `references …` clause. It contains `shared::REFERENTIAL_CLAUSES`
|
||||||
|
// (the `referential_actions` concept), so a cursor on `references
|
||||||
|
// <parent>` resolves to `foreign_key` while a cursor on the inner `on
|
||||||
|
// delete …` resolves to the narrower `referential_actions` (D2
|
||||||
|
// innermost-wins).
|
||||||
|
const REFERENCES_CONCEPT: Node = Node::Concept {
|
||||||
|
topic: "foreign_key",
|
||||||
|
inner: &REFERENCES_CLAUSE,
|
||||||
|
};
|
||||||
// `NOT NULL` | `UNIQUE` | `PRIMARY KEY` | `DEFAULT <expr>` |
|
// `NOT NULL` | `UNIQUE` | `PRIMARY KEY` | `DEFAULT <expr>` |
|
||||||
// `CHECK (<expr>)`. Each branch starts on a distinct keyword, so the
|
// `CHECK (<expr>)`. Each branch starts on a distinct keyword, so the
|
||||||
// `Choice` never ambiguously commits.
|
// `Choice` never ambiguously commits. The relational-concept branches
|
||||||
|
// (`unique`, `primary key`, `check`, `references`) carry a
|
||||||
|
// `Node::Concept` tag (issue #37); `not null` / `default` deliberately
|
||||||
|
// do not (D4 omission).
|
||||||
static COL_CONSTRAINT_CHOICES: &[Node] = &[
|
static COL_CONSTRAINT_CHOICES: &[Node] = &[
|
||||||
Node::Seq(NOT_NULL_NODES),
|
Node::Seq(NOT_NULL_NODES),
|
||||||
Node::Word(Word::keyword("unique")),
|
Node::Concept {
|
||||||
Node::Seq(PRIMARY_KEY_NODES),
|
topic: "unique",
|
||||||
|
inner: &Node::Word(Word::keyword("unique")),
|
||||||
|
},
|
||||||
|
Node::Concept {
|
||||||
|
topic: "primary_key",
|
||||||
|
inner: &Node::Seq(PRIMARY_KEY_NODES),
|
||||||
|
},
|
||||||
Node::Seq(DEFAULT_NODES),
|
Node::Seq(DEFAULT_NODES),
|
||||||
Node::Seq(CHECK_NODES),
|
Node::Concept {
|
||||||
REFERENCES_CLAUSE,
|
topic: "check",
|
||||||
|
inner: &Node::Seq(CHECK_NODES),
|
||||||
|
},
|
||||||
|
REFERENCES_CONCEPT,
|
||||||
];
|
];
|
||||||
const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES);
|
const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES);
|
||||||
/// Zero-or-more column constraints after the type (`min: 0`).
|
/// Zero-or-more column constraints after the type (`min: 0`).
|
||||||
@@ -405,8 +427,14 @@ const TABLE_FK_NAMED: Node = Node::Seq(TABLE_FK_NAMED_NODES);
|
|||||||
// / `foreign`) that disambiguates it from a column name. (A column
|
// / `foreign`) that disambiguates it from a column name. (A column
|
||||||
// literally named with one of those keywords is therefore unavailable,
|
// literally named with one of those keywords is therefore unavailable,
|
||||||
// the same trade real SQL makes with its reserved words.)
|
// the same trade real SQL makes with its reserved words.)
|
||||||
static ELEMENT_CHOICES: &[Node] =
|
static ELEMENT_CHOICES: &[Node] = &[
|
||||||
&[TABLE_PK, TABLE_UNIQUE, TABLE_CHECK, TABLE_FK_NAMED, TABLE_FK, COLUMN_DEF];
|
TABLE_PK,
|
||||||
|
TABLE_UNIQUE,
|
||||||
|
TABLE_CHECK,
|
||||||
|
TABLE_FK_NAMED,
|
||||||
|
TABLE_FK,
|
||||||
|
COLUMN_DEF,
|
||||||
|
];
|
||||||
const ELEMENT_INNER: Node = Node::Choice(ELEMENT_CHOICES);
|
const ELEMENT_INNER: Node = Node::Choice(ELEMENT_CHOICES);
|
||||||
// Issue #4: wrap the element slot in `IntroProse` so a fresh element
|
// Issue #4: wrap the element slot in `IntroProse` so a fresh element
|
||||||
// position (`create table T (` and after every `,`) surfaces a prose
|
// position (`create table T (` and after every `,`) surfaces a prose
|
||||||
@@ -495,18 +523,31 @@ mod tests {
|
|||||||
let mut ctx = WalkContext::new();
|
let mut ctx = WalkContext::new();
|
||||||
let mut path = MatchedPath::new();
|
let mut path = MatchedPath::new();
|
||||||
let mut per_byte = Vec::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(),
|
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn good(input: &str) {
|
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) {
|
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]
|
#[test]
|
||||||
@@ -548,7 +589,7 @@ mod tests {
|
|||||||
fn standard_sql_type_aliases() {
|
fn standard_sql_type_aliases() {
|
||||||
good("table t (a integer, b varchar, c boolean, d timestamp)");
|
good("table t (a integer, b varchar, c boolean, d timestamp)");
|
||||||
good("table t (e bigint, f smallint, g char, h numeric)");
|
good("table t (e bigint, f smallint, g char, h numeric)");
|
||||||
good("table t (i binary, j varbinary, k float)");
|
good("table t (k float)");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -638,7 +679,9 @@ mod tests {
|
|||||||
good("table t (id int, ref int references other(id))");
|
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)"); // 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 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, foreign key (ref) references other(id))");
|
||||||
good("table t (id int, ref int, constraint fk_x foreign key (ref) references other(id))");
|
good("table t (id int, ref int, constraint fk_x foreign key (ref) references other(id))");
|
||||||
good(
|
good(
|
||||||
@@ -691,7 +734,10 @@ mod builder_tests {
|
|||||||
assert_eq!(name, "t");
|
assert_eq!(name, "t");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cols,
|
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!(pk.is_empty(), "no PK declared");
|
||||||
assert!(!ine);
|
assert!(!ine);
|
||||||
@@ -710,7 +756,7 @@ mod builder_tests {
|
|||||||
fn standard_sql_aliases_map_to_playground_types() {
|
fn standard_sql_aliases_map_to_playground_types() {
|
||||||
let (_, cols, _, _) = sct(
|
let (_, cols, _, _) = sct(
|
||||||
"create table t (a bigint, b varchar, c boolean, d timestamp, \
|
"create table t (a bigint, b varchar, c boolean, d timestamp, \
|
||||||
e numeric, f float, g binary)",
|
e numeric, f float)",
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cols,
|
cols,
|
||||||
@@ -721,7 +767,6 @@ mod builder_tests {
|
|||||||
("d".to_string(), Type::DateTime),
|
("d".to_string(), Type::DateTime),
|
||||||
("e".to_string(), Type::Decimal),
|
("e".to_string(), Type::Decimal),
|
||||||
("f".to_string(), Type::Real),
|
("f".to_string(), Type::Real),
|
||||||
("g".to_string(), Type::Blob),
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -740,7 +785,10 @@ mod builder_tests {
|
|||||||
let (_, cols, _, _) = sct("create table t (a varchar(255), b numeric(10, 2))");
|
let (_, cols, _, _) = sct("create table t (a varchar(255), b numeric(10, 2))");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cols,
|
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 +828,7 @@ mod builder_tests {
|
|||||||
fn redundant_constraints_deduped_off_sole_pk_column() {
|
fn redundant_constraints_deduped_off_sole_pk_column() {
|
||||||
// ADR-0035 §6.5: advanced mode accepts the redundant spelling
|
// ADR-0035 §6.5: advanced mode accepts the redundant spelling
|
||||||
// and silently drops the flags off the sole PK column.
|
// and silently drops the flags off the sole PK column.
|
||||||
match parse_command("create table t (id int primary key not null unique)")
|
match parse_command("create table t (id int primary key not null unique)").expect("parses")
|
||||||
.expect("parses")
|
|
||||||
{
|
{
|
||||||
Command::SqlCreateTable {
|
Command::SqlCreateTable {
|
||||||
columns,
|
columns,
|
||||||
@@ -944,8 +991,7 @@ mod builder_tests {
|
|||||||
// depth 2, not an element boundary, so the following `check`
|
// depth 2, not an element boundary, so the following `check`
|
||||||
// is still column-level. A naive "reset on any comma" would
|
// is still column-level. A naive "reset on any comma" would
|
||||||
// misclassify it as table-level (the §4.2 probe).
|
// misclassify it as table-level (the §4.2 probe).
|
||||||
let (cols, checks) =
|
let (cols, checks) = parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
|
||||||
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_eq!(col(&cols, "n").check_sql.as_deref(), Some("n > 0"));
|
||||||
assert!(checks.is_empty(), "no table-level CHECK was produced");
|
assert!(checks.is_empty(), "no table-level CHECK was produced");
|
||||||
}
|
}
|
||||||
@@ -977,8 +1023,7 @@ mod builder_tests {
|
|||||||
fn table_check_before_a_later_column_is_table_level() {
|
fn table_check_before_a_later_column_is_table_level() {
|
||||||
// A CHECK element that appears between columns (not after a
|
// A CHECK element that appears between columns (not after a
|
||||||
// column's type) is table-level even though more columns follow.
|
// column's type) is table-level even though more columns follow.
|
||||||
let (cols, checks) =
|
let (cols, checks) = parse_sct_checks("create table t (a int, check (a > 0), b int)");
|
||||||
parse_sct_checks("create table t (a int, check (a > 0), b int)");
|
|
||||||
assert_eq!(checks, vec!["a > 0".to_string()]);
|
assert_eq!(checks, vec!["a > 0".to_string()]);
|
||||||
assert!(col(&cols, "a").check_sql.is_none() && col(&cols, "b").check_sql.is_none());
|
assert!(col(&cols, "a").check_sql.is_none() && col(&cols, "b").check_sql.is_none());
|
||||||
}
|
}
|
||||||
@@ -1004,7 +1049,10 @@ mod builder_tests {
|
|||||||
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
|
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
|
||||||
assert_eq!(fk.on_delete, ReferentialAction::NoAction);
|
assert_eq!(fk.on_delete, ReferentialAction::NoAction);
|
||||||
assert_eq!(fk.on_update, 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]
|
#[test]
|
||||||
@@ -1012,14 +1060,19 @@ mod builder_tests {
|
|||||||
// The table-level `FOREIGN KEY (...)` form is not inline, so it can
|
// The table-level `FOREIGN KEY (...)` form is not inline, so it can
|
||||||
// carry a multi-column reference and never triggers the inline
|
// carry a multi-column reference and never triggers the inline
|
||||||
// "use the table-level form" hint (ADR-0043 D4).
|
// "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");
|
assert!(!fks[0].inline, "table-level FOREIGN KEY is not inline");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bare_inline_reference_has_no_parent_column() {
|
fn bare_inline_reference_has_no_parent_column() {
|
||||||
let fks = parse_sct_fks("create table t (id int, pid int references parent)");
|
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].parent_table, "parent");
|
||||||
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
|
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
|
||||||
}
|
}
|
||||||
@@ -1047,8 +1100,9 @@ mod builder_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn table_level_foreign_key_captured() {
|
fn table_level_foreign_key_captured() {
|
||||||
let fks =
|
let fks = parse_sct_fks(
|
||||||
parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
|
"create table t (id int, pid int, foreign key (pid) references parent(id))",
|
||||||
|
);
|
||||||
assert_eq!(fks.len(), 1);
|
assert_eq!(fks.len(), 1);
|
||||||
assert_eq!(fks[0].name, None);
|
assert_eq!(fks[0].name, None);
|
||||||
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
|
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
|
||||||
@@ -1073,8 +1127,20 @@ mod builder_tests {
|
|||||||
foreign key (a) references p(id), foreign key (b) references q(id))",
|
foreign key (a) references p(id), foreign key (b) references q(id))",
|
||||||
);
|
);
|
||||||
assert_eq!(fks.len(), 2);
|
assert_eq!(fks.len(), 2);
|
||||||
assert_eq!((fks[0].child_columns[0].as_str(), fks[0].parent_table.as_str()), ("a", "p"));
|
assert_eq!(
|
||||||
assert_eq!((fks[1].child_columns[0].as_str(), fks[1].parent_table.as_str()), ("b", "q"));
|
(
|
||||||
|
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]
|
#[test]
|
||||||
@@ -1108,7 +1174,12 @@ mod builder_tests {
|
|||||||
assert_eq!(foreign_keys[0].child_columns, vec!["pid".to_string()]);
|
assert_eq!(foreign_keys[0].child_columns, vec!["pid".to_string()]);
|
||||||
// the column-level CHECK still attaches to `pid`
|
// the column-level CHECK still attaches to `pid`
|
||||||
assert_eq!(
|
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")
|
Some("pid > 0")
|
||||||
);
|
);
|
||||||
// the table-level CHECK is captured separately
|
// the table-level CHECK is captured separately
|
||||||
|
|||||||
@@ -82,7 +82,14 @@ mod tests {
|
|||||||
let mut ctx = WalkContext::new();
|
let mut ctx = WalkContext::new();
|
||||||
let mut path = MatchedPath::new();
|
let mut path = MatchedPath::new();
|
||||||
let mut per_byte = Vec::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(),
|
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
@@ -93,7 +100,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn bad(input: &str) {
|
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]
|
#[test]
|
||||||
|
|||||||
+21
-53
@@ -82,19 +82,16 @@ const EXPR_IDENT: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// or_expr := and_expr ( OR and_expr )* — the fragment entry point
|
// or_expr := and_expr ( OR and_expr )* — the fragment entry point
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
static OR_TAIL_NODES: &[Node] = &[
|
static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
|
||||||
Node::Word(Word::keyword("or")),
|
|
||||||
Node::Subgrammar(&AND_EXPR),
|
|
||||||
];
|
|
||||||
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
|
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
|
||||||
static SQL_OR_EXPR_NODES: &[Node] = &[
|
static SQL_OR_EXPR_NODES: &[Node] = &[
|
||||||
Node::Subgrammar(&AND_EXPR),
|
Node::Subgrammar(&AND_EXPR),
|
||||||
@@ -140,10 +137,7 @@ static NOT_FORM_NODES: &[Node] = &[
|
|||||||
Node::Word(Word::keyword("not")),
|
Node::Word(Word::keyword("not")),
|
||||||
Node::Subgrammar(&NOT_EXPR),
|
Node::Subgrammar(&NOT_EXPR),
|
||||||
];
|
];
|
||||||
static NOT_EXPR_CHOICES: &[Node] = &[
|
static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&PREDICATE)];
|
||||||
Node::Seq(NOT_FORM_NODES),
|
|
||||||
Node::Subgrammar(&PREDICATE),
|
|
||||||
];
|
|
||||||
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
|
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
|
// needs. ADR-0026's DSL grammar made the tail mandatory because it
|
||||||
// forbade a bare column as a boolean; SQL does not.
|
// forbade a bare column as a boolean; SQL does not.
|
||||||
|
|
||||||
static PREDICATE_NODES: &[Node] = &[
|
static PREDICATE_NODES: &[Node] = &[Node::Subgrammar(&ADDITIVE), Node::Optional(&PREDICATE_TAIL)];
|
||||||
Node::Subgrammar(&ADDITIVE),
|
|
||||||
Node::Optional(&PREDICATE_TAIL),
|
|
||||||
];
|
|
||||||
static PREDICATE: Node = Node::Seq(PREDICATE_NODES);
|
static PREDICATE: Node = Node::Seq(PREDICATE_NODES);
|
||||||
|
|
||||||
// ---- cmp_op := <= | <> | >= | != | < | > | = --------------------
|
// ---- cmp_op := <= | <> | >= | != | < | > | = --------------------
|
||||||
@@ -181,10 +172,7 @@ static CMP_OP_CHOICES: &[Node] = &[
|
|||||||
// ---- predicate_tail branches ------------------------------------
|
// ---- predicate_tail branches ------------------------------------
|
||||||
|
|
||||||
/// `cmp_op additive`.
|
/// `cmp_op additive`.
|
||||||
static COMPARE_FORM_NODES: &[Node] = &[
|
static COMPARE_FORM_NODES: &[Node] = &[Node::Choice(CMP_OP_CHOICES), Node::Subgrammar(&ADDITIVE)];
|
||||||
Node::Choice(CMP_OP_CHOICES),
|
|
||||||
Node::Subgrammar(&ADDITIVE),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// `IS [NOT] NULL`.
|
/// `IS [NOT] NULL`.
|
||||||
static IS_NULL_NODES: &[Node] = &[
|
static IS_NULL_NODES: &[Node] = &[
|
||||||
@@ -265,11 +253,7 @@ static PREDICATE_TAIL: Node = Node::Choice(PREDICATE_TAIL_CHOICES);
|
|||||||
// additive := multiplicative ( ( + | - | || ) multiplicative )*
|
// additive := multiplicative ( ( + | - | || ) multiplicative )*
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
static ADD_OP_CHOICES: &[Node] = &[
|
static ADD_OP_CHOICES: &[Node] = &[Node::Punct('+'), Node::Punct('-'), Node::Literal("||")];
|
||||||
Node::Punct('+'),
|
|
||||||
Node::Punct('-'),
|
|
||||||
Node::Literal("||"),
|
|
||||||
];
|
|
||||||
static ADD_TAIL_NODES: &[Node] = &[
|
static ADD_TAIL_NODES: &[Node] = &[
|
||||||
Node::Choice(ADD_OP_CHOICES),
|
Node::Choice(ADD_OP_CHOICES),
|
||||||
Node::Subgrammar(&MULTIPLICATIVE),
|
Node::Subgrammar(&MULTIPLICATIVE),
|
||||||
@@ -289,15 +273,8 @@ static ADDITIVE: Node = Node::Seq(ADDITIVE_NODES);
|
|||||||
// multiplicative := unary ( ( * | / | % ) unary )*
|
// multiplicative := unary ( ( * | / | % ) unary )*
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
static MUL_OP_CHOICES: &[Node] = &[
|
static MUL_OP_CHOICES: &[Node] = &[Node::Punct('*'), Node::Punct('/'), Node::Punct('%')];
|
||||||
Node::Punct('*'),
|
static MUL_TAIL_NODES: &[Node] = &[Node::Choice(MUL_OP_CHOICES), Node::Subgrammar(&UNARY)];
|
||||||
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 MUL_TAIL: Node = Node::Seq(MUL_TAIL_NODES);
|
||||||
static MULTIPLICATIVE_NODES: &[Node] = &[
|
static MULTIPLICATIVE_NODES: &[Node] = &[
|
||||||
Node::Subgrammar(&UNARY),
|
Node::Subgrammar(&UNARY),
|
||||||
@@ -314,14 +291,8 @@ static MULTIPLICATIVE: Node = Node::Seq(MULTIPLICATIVE_NODES);
|
|||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
static SIGN_CHOICES: &[Node] = &[Node::Punct('-'), Node::Punct('+')];
|
static SIGN_CHOICES: &[Node] = &[Node::Punct('-'), Node::Punct('+')];
|
||||||
static UNARY_SIGN_NODES: &[Node] = &[
|
static UNARY_SIGN_NODES: &[Node] = &[Node::Choice(SIGN_CHOICES), Node::Subgrammar(&UNARY)];
|
||||||
Node::Choice(SIGN_CHOICES),
|
static UNARY_CHOICES: &[Node] = &[Node::Seq(UNARY_SIGN_NODES), Node::Subgrammar(&PRIMARY)];
|
||||||
Node::Subgrammar(&UNARY),
|
|
||||||
];
|
|
||||||
static UNARY_CHOICES: &[Node] = &[
|
|
||||||
Node::Seq(UNARY_SIGN_NODES),
|
|
||||||
Node::Subgrammar(&PRIMARY),
|
|
||||||
];
|
|
||||||
static UNARY: Node = Node::Choice(UNARY_CHOICES);
|
static UNARY: Node = Node::Choice(UNARY_CHOICES);
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -402,10 +373,7 @@ static SIMPLE_CASE_NODES: &[Node] = &[
|
|||||||
Node::Optional(&ELSE_CLAUSE),
|
Node::Optional(&ELSE_CLAUSE),
|
||||||
Node::Word(Word::keyword("end")),
|
Node::Word(Word::keyword("end")),
|
||||||
];
|
];
|
||||||
static CASE_BODY_CHOICES: &[Node] = &[
|
static CASE_BODY_CHOICES: &[Node] = &[Node::Seq(SEARCHED_CASE_NODES), Node::Seq(SIMPLE_CASE_NODES)];
|
||||||
Node::Seq(SEARCHED_CASE_NODES),
|
|
||||||
Node::Seq(SIMPLE_CASE_NODES),
|
|
||||||
];
|
|
||||||
static CASE_NODES: &[Node] = &[
|
static CASE_NODES: &[Node] = &[
|
||||||
Node::Word(Word::keyword("case")),
|
Node::Word(Word::keyword("case")),
|
||||||
Node::Choice(CASE_BODY_CHOICES),
|
Node::Choice(CASE_BODY_CHOICES),
|
||||||
@@ -467,14 +435,11 @@ const QUALIFIED_REF_IDENT: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[
|
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[Node::Punct('.'), QUALIFIED_REF_IDENT];
|
||||||
Node::Punct('.'),
|
|
||||||
QUALIFIED_REF_IDENT,
|
|
||||||
];
|
|
||||||
|
|
||||||
static NAME_OR_CALL_TAIL_CHOICES: &[Node] = &[
|
static NAME_OR_CALL_TAIL_CHOICES: &[Node] = &[
|
||||||
Node::Seq(QUALIFIED_REF_TAIL_NODES),
|
Node::Seq(QUALIFIED_REF_TAIL_NODES),
|
||||||
@@ -531,7 +496,10 @@ mod tests {
|
|||||||
|
|
||||||
/// Assert `input` is *not* a complete SQL expression.
|
/// Assert `input` is *not* a complete SQL expression.
|
||||||
fn bad(input: &str) {
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -120,7 +120,10 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
|
|||||||
listed
|
listed
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|name| {
|
.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()
|
.collect()
|
||||||
},
|
},
|
||||||
@@ -148,7 +151,11 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
|
|||||||
fn tuple_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
|
fn tuple_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
|
||||||
let cols = target_value_columns(ctx);
|
let cols = target_value_columns(ctx);
|
||||||
let (count, closed) = count_tuple_values(source, pos);
|
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 {
|
if !cols.is_empty() && arity_ok {
|
||||||
Node::DynamicSubgrammar(sql_value_list)
|
Node::DynamicSubgrammar(sql_value_list)
|
||||||
} else {
|
} else {
|
||||||
@@ -304,8 +311,10 @@ static DO_UPDATE_NODES: &[Node] = &[
|
|||||||
/// the enclosing Seq, each branch's FIRST token (`nothing` vs
|
/// the enclosing Seq, each branch's FIRST token (`nothing` vs
|
||||||
/// `update`) disambiguates, so a non-match of branch 0 is a clean
|
/// `update`) disambiguates, so a non-match of branch 0 is a clean
|
||||||
/// `NoMatch` that falls through to branch 1.
|
/// `NoMatch` that falls through to branch 1.
|
||||||
static DO_ACTION_CHOICES: &[Node] =
|
static DO_ACTION_CHOICES: &[Node] = &[
|
||||||
&[Node::Word(Word::keyword("nothing")), Node::Seq(DO_UPDATE_NODES)];
|
Node::Word(Word::keyword("nothing")),
|
||||||
|
Node::Seq(DO_UPDATE_NODES),
|
||||||
|
];
|
||||||
// `const` — used by value in `ON_CONFLICT_CLAUSE_NODES`.
|
// `const` — used by value in `ON_CONFLICT_CLAUSE_NODES`.
|
||||||
const DO_ACTION: Node = Node::Choice(DO_ACTION_CHOICES);
|
const DO_ACTION: Node = Node::Choice(DO_ACTION_CHOICES);
|
||||||
|
|
||||||
@@ -361,7 +370,14 @@ mod tests {
|
|||||||
let mut ctx = WalkContext::new();
|
let mut ctx = WalkContext::new();
|
||||||
let mut path = MatchedPath::new();
|
let mut path = MatchedPath::new();
|
||||||
let mut per_byte = Vec::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(),
|
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
@@ -372,7 +388,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn bad(input: &str) {
|
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]
|
#[test]
|
||||||
@@ -418,8 +437,12 @@ mod tests {
|
|||||||
// 3h: ON CONFLICT … DO NOTHING / DO UPDATE (ADR-0033 §9).
|
// 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 (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 do nothing");
|
||||||
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name");
|
good(
|
||||||
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0");
|
"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.
|
// 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");
|
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,
|
// 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
|
/// suffix keywords. `as` is not listed — the AS-form alias is a
|
||||||
/// separate `Choice` branch that fires before the lookahead.
|
/// separate `Choice` branch that fires before the lookahead.
|
||||||
const PROJECTION_FOLLOW_SET: &[&str] = &[
|
const PROJECTION_FOLLOW_SET: &[&str] = &[
|
||||||
"from", "where", "group", "order", "having", "limit",
|
"from",
|
||||||
"union", "intersect", "except",
|
"where",
|
||||||
|
"group",
|
||||||
|
"order",
|
||||||
|
"having",
|
||||||
|
"limit",
|
||||||
|
"union",
|
||||||
|
"intersect",
|
||||||
|
"except",
|
||||||
// `returning` belongs to an enclosing DML statement
|
// `returning` belongs to an enclosing DML statement
|
||||||
// (`INSERT … SELECT … RETURNING …`, ADR-0033 §5), never to a
|
// (`INSERT … SELECT … RETURNING …`, ADR-0033 §5), never to a
|
||||||
// projection item's bare alias — so a no-FROM SELECT row source
|
// 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
|
/// only when `b` has no alias — `on` is not a base-table name a
|
||||||
/// learner would type as an alias.
|
/// learner would type as an alias.
|
||||||
const TABLE_SOURCE_FOLLOW_SET: &[&str] = &[
|
const TABLE_SOURCE_FOLLOW_SET: &[&str] = &[
|
||||||
"where", "group", "order", "having", "limit",
|
"where",
|
||||||
"union", "intersect", "except",
|
"group",
|
||||||
"inner", "left", "right", "full", "cross", "join", "on",
|
"order",
|
||||||
|
"having",
|
||||||
|
"limit",
|
||||||
|
"union",
|
||||||
|
"intersect",
|
||||||
|
"except",
|
||||||
|
"inner",
|
||||||
|
"left",
|
||||||
|
"right",
|
||||||
|
"full",
|
||||||
|
"cross",
|
||||||
|
"join",
|
||||||
|
"on",
|
||||||
// `returning` belongs to an enclosing DML statement
|
// `returning` belongs to an enclosing DML statement
|
||||||
// (`INSERT … SELECT … FROM t RETURNING …`, ADR-0033 §5), so the
|
// (`INSERT … SELECT … FROM t RETURNING …`, ADR-0033 §5), so the
|
||||||
// SELECT row source must not read it as table `t`'s bare alias.
|
// 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())
|
consume_ident(source, p).map(|(s, e)| source[s..e].to_ascii_lowercase())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn projection_bare_alias_factory(
|
fn projection_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
|
||||||
_: &WalkContext,
|
|
||||||
source: &str,
|
|
||||||
pos: usize,
|
|
||||||
) -> Node {
|
|
||||||
match peek_next_ident_lower(source, pos) {
|
match peek_next_ident_lower(source, pos) {
|
||||||
Some(word)
|
Some(word) if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) => {
|
||||||
if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) =>
|
|
||||||
{
|
|
||||||
Node::Subgrammar(&EMPTY_NOMATCH)
|
Node::Subgrammar(&EMPTY_NOMATCH)
|
||||||
}
|
}
|
||||||
Some(_) => PROJECTION_BARE_ALIAS_IDENT,
|
Some(_) => PROJECTION_BARE_ALIAS_IDENT,
|
||||||
@@ -188,15 +201,9 @@ fn projection_bare_alias_factory(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn table_source_bare_alias_factory(
|
fn table_source_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
|
||||||
_: &WalkContext,
|
|
||||||
source: &str,
|
|
||||||
pos: usize,
|
|
||||||
) -> Node {
|
|
||||||
match peek_next_ident_lower(source, pos) {
|
match peek_next_ident_lower(source, pos) {
|
||||||
Some(word)
|
Some(word) if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) => {
|
||||||
if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) =>
|
|
||||||
{
|
|
||||||
Node::Subgrammar(&EMPTY_NOMATCH)
|
Node::Subgrammar(&EMPTY_NOMATCH)
|
||||||
}
|
}
|
||||||
Some(_) => TABLE_SOURCE_BARE_ALIAS_IDENT,
|
Some(_) => TABLE_SOURCE_BARE_ALIAS_IDENT,
|
||||||
@@ -237,14 +244,12 @@ const TABLE_SOURCE_BARE_ALIAS_IDENT: Node = Node::Ident {
|
|||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: true,
|
writes_table_alias: true,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
static PROJECTION_AS_ALIAS_NODES: &[Node] = &[
|
static PROJECTION_AS_ALIAS_NODES: &[Node] =
|
||||||
Node::Word(Word::keyword("as")),
|
&[Node::Word(Word::keyword("as")), PROJECTION_BARE_ALIAS_IDENT];
|
||||||
PROJECTION_BARE_ALIAS_IDENT,
|
|
||||||
];
|
|
||||||
static PROJECTION_AS_ALIAS: Node = Node::Seq(PROJECTION_AS_ALIAS_NODES);
|
static PROJECTION_AS_ALIAS: Node = Node::Seq(PROJECTION_AS_ALIAS_NODES);
|
||||||
|
|
||||||
static TABLE_SOURCE_AS_ALIAS_NODES: &[Node] = &[
|
static TABLE_SOURCE_AS_ALIAS_NODES: &[Node] = &[
|
||||||
@@ -258,17 +263,14 @@ static PROJECTION_ALIAS_CHOICES: &[Node] = &[
|
|||||||
Node::Lookahead(projection_bare_alias_factory),
|
Node::Lookahead(projection_bare_alias_factory),
|
||||||
];
|
];
|
||||||
static PROJECTION_ALIAS_CHOICE: Node = Node::Choice(PROJECTION_ALIAS_CHOICES);
|
static PROJECTION_ALIAS_CHOICE: Node = Node::Choice(PROJECTION_ALIAS_CHOICES);
|
||||||
static PROJECTION_ALIAS_OPTIONAL: Node =
|
static PROJECTION_ALIAS_OPTIONAL: Node = Node::Optional(&PROJECTION_ALIAS_CHOICE);
|
||||||
Node::Optional(&PROJECTION_ALIAS_CHOICE);
|
|
||||||
|
|
||||||
static TABLE_SOURCE_ALIAS_CHOICES: &[Node] = &[
|
static TABLE_SOURCE_ALIAS_CHOICES: &[Node] = &[
|
||||||
Node::Subgrammar(&TABLE_SOURCE_AS_ALIAS),
|
Node::Subgrammar(&TABLE_SOURCE_AS_ALIAS),
|
||||||
Node::Lookahead(table_source_bare_alias_factory),
|
Node::Lookahead(table_source_bare_alias_factory),
|
||||||
];
|
];
|
||||||
static TABLE_SOURCE_ALIAS_CHOICE: Node =
|
static TABLE_SOURCE_ALIAS_CHOICE: Node = Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
|
||||||
Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
|
static TABLE_SOURCE_ALIAS_OPTIONAL: Node = Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
|
||||||
static TABLE_SOURCE_ALIAS_OPTIONAL: Node =
|
|
||||||
Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
|
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// Projection item
|
// Projection item
|
||||||
@@ -282,16 +284,13 @@ const QUALIFIED_STAR_QUALIFIER: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
static QUALIFIED_STAR_NODES: &[Node] = &[
|
static QUALIFIED_STAR_NODES: &[Node] =
|
||||||
QUALIFIED_STAR_QUALIFIER,
|
&[QUALIFIED_STAR_QUALIFIER, Node::Punct('.'), Node::Punct('*')];
|
||||||
Node::Punct('.'),
|
|
||||||
Node::Punct('*'),
|
|
||||||
];
|
|
||||||
static QUALIFIED_STAR: Node = Node::Seq(QUALIFIED_STAR_NODES);
|
static QUALIFIED_STAR: Node = Node::Seq(QUALIFIED_STAR_NODES);
|
||||||
|
|
||||||
static PROJECTION_EXPR_ITEM_NODES: &[Node] = &[
|
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
|
/// ambiguity between `t.*` and `sql_expr` (which can match a
|
||||||
/// bare `t`), since the walker's `Choice` doesn't backtrack on
|
/// bare `t`), since the walker's `Choice` doesn't backtrack on
|
||||||
/// a committed match.
|
/// a committed match.
|
||||||
fn projection_item_factory(
|
fn projection_item_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
|
||||||
_: &WalkContext,
|
|
||||||
source: &str,
|
|
||||||
pos: usize,
|
|
||||||
) -> Node {
|
|
||||||
let p = skip_whitespace(source, pos);
|
let p = skip_whitespace(source, pos);
|
||||||
let bytes = source.as_bytes();
|
let bytes = source.as_bytes();
|
||||||
if bytes.get(p) == Some(&b'*') {
|
if bytes.get(p) == Some(&b'*') {
|
||||||
@@ -363,8 +358,7 @@ static DISTINCT_OR_ALL_CHOICES: &[Node] = &[
|
|||||||
Node::Word(Word::keyword("all")),
|
Node::Word(Word::keyword("all")),
|
||||||
];
|
];
|
||||||
static DISTINCT_OR_ALL_CHOICE: Node = Node::Choice(DISTINCT_OR_ALL_CHOICES);
|
static DISTINCT_OR_ALL_CHOICE: Node = Node::Choice(DISTINCT_OR_ALL_CHOICES);
|
||||||
static DISTINCT_OR_ALL_OPTIONAL: Node =
|
static DISTINCT_OR_ALL_OPTIONAL: Node = Node::Optional(&DISTINCT_OR_ALL_CHOICE);
|
||||||
Node::Optional(&DISTINCT_OR_ALL_CHOICE);
|
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// Table source (FROM / JOIN target)
|
// Table source (FROM / JOIN target)
|
||||||
@@ -379,8 +373,8 @@ const TABLE_NAME_IDENT: Node = Node::Ident {
|
|||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
static TABLE_SOURCE_NODES: &[Node] = &[
|
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 JOIN_WORD: Node = Node::Word(Word::keyword("join"));
|
||||||
const ON_WORD: Node = Node::Word(Word::keyword("on"));
|
const ON_WORD: Node = Node::Word(Word::keyword("on"));
|
||||||
static OUTER_OPTIONAL: Node =
|
static OUTER_OPTIONAL: Node = Node::Optional(&Node::Word(Word::keyword("outer")));
|
||||||
Node::Optional(&Node::Word(Word::keyword("outer")));
|
|
||||||
|
|
||||||
// `INNER JOIN` and bare `JOIN` are split into two Choice
|
// `INNER JOIN` and bare `JOIN` are split into two Choice
|
||||||
// branches so each branch has a distinct leading keyword
|
// 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: Node = Node::Choice(SET_OP_CHOICES);
|
||||||
|
|
||||||
static SET_OP_TAIL_NODES: &[Node] =
|
static SET_OP_TAIL_NODES: &[Node] = &[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
|
||||||
&[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
|
|
||||||
static SET_OP_TAIL: Node = Node::Seq(SET_OP_TAIL_NODES);
|
static SET_OP_TAIL: Node = Node::Seq(SET_OP_TAIL_NODES);
|
||||||
|
|
||||||
static PLAIN_COMPOUND_NODES: &[Node] = &[
|
static PLAIN_COMPOUND_NODES: &[Node] = &[
|
||||||
@@ -619,8 +611,7 @@ static WITH_PREFIXED_COMPOUND_NODES: &[Node] = &[
|
|||||||
Node::Subgrammar(&WITH_CLAUSE),
|
Node::Subgrammar(&WITH_CLAUSE),
|
||||||
Node::Subgrammar(&PLAIN_COMPOUND),
|
Node::Subgrammar(&PLAIN_COMPOUND),
|
||||||
];
|
];
|
||||||
static WITH_PREFIXED_COMPOUND: Node =
|
static WITH_PREFIXED_COMPOUND: Node = Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
|
||||||
Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
|
|
||||||
|
|
||||||
static COMPOUND_CHOICES: &[Node] = &[
|
static COMPOUND_CHOICES: &[Node] = &[
|
||||||
Node::Subgrammar(&WITH_PREFIXED_COMPOUND),
|
Node::Subgrammar(&WITH_PREFIXED_COMPOUND),
|
||||||
@@ -659,9 +650,9 @@ const CTE_COLUMN_IDENT: Node = Node::Ident {
|
|||||||
writes_table: false,
|
writes_table: false,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
static CTE_COLUMN_LIST_NODES: &[Node] = &[
|
static CTE_COLUMN_LIST_NODES: &[Node] = &[
|
||||||
@@ -674,18 +665,13 @@ static CTE_COLUMN_LIST_NODES: &[Node] = &[
|
|||||||
RPAREN,
|
RPAREN,
|
||||||
];
|
];
|
||||||
static CTE_COLUMN_LIST_SEQ: Node = Node::Seq(CTE_COLUMN_LIST_NODES);
|
static CTE_COLUMN_LIST_SEQ: Node = Node::Seq(CTE_COLUMN_LIST_NODES);
|
||||||
static CTE_COLUMN_LIST_OPTIONAL: Node =
|
static CTE_COLUMN_LIST_OPTIONAL: Node = Node::Optional(&CTE_COLUMN_LIST_SEQ);
|
||||||
Node::Optional(&CTE_COLUMN_LIST_SEQ);
|
|
||||||
|
|
||||||
// CTE body recursion pushes a fresh lexical scope frame (ADR-
|
// CTE body recursion pushes a fresh lexical scope frame (ADR-
|
||||||
// 0032 §4 / §10.2). Subqueries in `sql_expr.rs` do the same;
|
// 0032 §4 / §10.2). Subqueries in `sql_expr.rs` do the same;
|
||||||
// the top-level statement's own COMPOUND embedding does not
|
// the top-level statement's own COMPOUND embedding does not
|
||||||
// (it shares the implicit bottom frame).
|
// (it shares the implicit bottom frame).
|
||||||
static CTE_BODY_NODES: &[Node] = &[
|
static CTE_BODY_NODES: &[Node] = &[LPAREN, Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND), RPAREN];
|
||||||
LPAREN,
|
|
||||||
Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND),
|
|
||||||
RPAREN,
|
|
||||||
];
|
|
||||||
static CTE_BODY: Node = Node::Seq(CTE_BODY_NODES);
|
static CTE_BODY: Node = Node::Seq(CTE_BODY_NODES);
|
||||||
|
|
||||||
static CTE_DEF_NODES: &[Node] = &[
|
static CTE_DEF_NODES: &[Node] = &[
|
||||||
@@ -807,9 +793,7 @@ mod tests {
|
|||||||
let mut path = MatchedPath::new();
|
let mut path = MatchedPath::new();
|
||||||
let mut per_byte = Vec::new();
|
let mut per_byte = Vec::new();
|
||||||
match walk_node(input, 0, fragment, &mut ctx, &mut path, &mut per_byte) {
|
match walk_node(input, 0, fragment, &mut ctx, &mut path, &mut per_byte) {
|
||||||
NodeWalkResult::Matched { end, .. } => {
|
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
|
||||||
input[end..].trim().is_empty()
|
|
||||||
}
|
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -819,10 +803,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn good(input: &str) {
|
fn good(input: &str) {
|
||||||
assert!(
|
assert!(walks(input), "{input:?} should be a valid SELECT statement");
|
||||||
walks(input),
|
|
||||||
"{input:?} should be a valid SELECT statement"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bad(input: &str) {
|
fn bad(input: &str) {
|
||||||
@@ -1051,16 +1032,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_op_chain() {
|
fn set_op_chain() {
|
||||||
good(
|
good("select a from t union select b from u intersect select c from v");
|
||||||
"select a from t union select b from u intersect select c from v",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_op_with_outer_order_by_and_limit() {
|
fn set_op_with_outer_order_by_and_limit() {
|
||||||
good(
|
good("select a from t union select b from u order by a limit 10");
|
||||||
"select a from t union select b from u order by a limit 10",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- ORDER BY / LIMIT / OFFSET -----
|
// ----- ORDER BY / LIMIT / OFFSET -----
|
||||||
@@ -1126,16 +1103,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recursive_cte() {
|
fn recursive_cte() {
|
||||||
good(
|
good("with recursive r as (select 1 union all select 2) select * from r");
|
||||||
"with recursive r as (select 1 union all select 2) select * from r",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multiple_ctes() {
|
fn multiple_ctes() {
|
||||||
good(
|
good("with a as (select 1), b as (select 2) select * from a union select * from b");
|
||||||
"with a as (select 1), b as (select 2) select * from a union select * from b",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- subquery shapes (recursion through SQL_SELECT_COMPOUND) -----
|
// ----- subquery shapes (recursion through SQL_SELECT_COMPOUND) -----
|
||||||
@@ -1147,9 +1120,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nested_cte_body_with_union() {
|
fn nested_cte_body_with_union() {
|
||||||
good(
|
good("with x as (select 1 union select 2) select * from x");
|
||||||
"with x as (select 1 union select 2) select * from x",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- case insensitivity / spacing -----
|
// ----- case insensitivity / spacing -----
|
||||||
@@ -1363,9 +1334,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn in_subquery_in_where_clause() {
|
fn in_subquery_in_where_clause() {
|
||||||
good("select * from t where id in (select user_id from orders)");
|
good("select * from t where id in (select user_id from orders)");
|
||||||
good(
|
good("select * from customers where id not in (select customer_id from blocklist)");
|
||||||
"select * from customers where id not in (select customer_id from blocklist)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1378,9 +1347,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nested_subqueries() {
|
fn nested_subqueries() {
|
||||||
good(
|
good("select * from t where x in (select y from u where y in (select z from v))");
|
||||||
"select * from t where x in (select y from u where y in (select z from v))",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1393,8 +1360,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cte_body_references_qualified_columns() {
|
fn cte_body_references_qualified_columns() {
|
||||||
good(
|
good("with x as (select t.name, t.age from t) select x.name from x");
|
||||||
"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 ctx = WalkContext::new();
|
||||||
let mut path = MatchedPath::new();
|
let mut path = MatchedPath::new();
|
||||||
let mut per_byte = Vec::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(),
|
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
@@ -130,7 +137,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn bad(input: &str) {
|
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]
|
#[test]
|
||||||
|
|||||||
+3
-3
@@ -21,9 +21,9 @@ pub mod walker;
|
|||||||
|
|
||||||
pub use action::ReferentialAction;
|
pub use action::ReferentialAction;
|
||||||
pub use command::{
|
pub use command::{
|
||||||
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr,
|
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope,
|
||||||
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
|
Expr, IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector,
|
||||||
ShowListKind, SqlForeignKey,
|
RowFilter, ShowListKind, SqlForeignKey,
|
||||||
};
|
};
|
||||||
pub use parser::{ParseError, parse_command};
|
pub use parser::{ParseError, parse_command};
|
||||||
pub use types::Type;
|
pub use types::Type;
|
||||||
|
|||||||
+18
-25
@@ -55,10 +55,9 @@ pub enum ParseError {
|
|||||||
impl std::fmt::Display for ParseError {
|
impl std::fmt::Display for ParseError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Invalid { message, .. } => f.write_str(&crate::t!(
|
Self::Invalid { message, .. } => {
|
||||||
"parse.error_wrapper",
|
f.write_str(&crate::t!("parse.error_wrapper", detail = message,))
|
||||||
detail = message,
|
}
|
||||||
)),
|
|
||||||
Self::Empty => f.write_str(&crate::t!("parse.empty")),
|
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`
|
/// Schemaless, mode-aware parse (ADR-0030 §2). In `Mode::Simple`
|
||||||
/// the walker gates SQL-only commands and produces the
|
/// the walker gates SQL-only commands and produces the
|
||||||
/// "this is SQL" hint instead of executing them.
|
/// "this is SQL" hint instead of executing them.
|
||||||
pub fn parse_command_in_mode(
|
pub fn parse_command_in_mode(input: &str, mode: Mode) -> Result<Command, ParseError> {
|
||||||
input: &str,
|
|
||||||
mode: Mode,
|
|
||||||
) -> Result<Command, ParseError> {
|
|
||||||
parse_command_inner(input, None, mode)
|
parse_command_inner(input, None, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,10 +181,8 @@ fn unknown_command_error(source: &str) -> ParseError {
|
|||||||
.collect();
|
.collect();
|
||||||
let joined = oxford_join(&entries);
|
let joined = oxford_join(&entries);
|
||||||
let start = skip_whitespace(source, 0);
|
let start = skip_whitespace(source, 0);
|
||||||
let (position, found_word) = consume_ident(source, start).map_or_else(
|
let (position, found_word) = consume_ident(source, start)
|
||||||
|| (start, None),
|
.map_or_else(|| (start, None), |(s, e)| (s, Some(&source[s..e])));
|
||||||
|(s, e)| (s, Some(&source[s..e])),
|
|
||||||
);
|
|
||||||
let message = found_word.map_or_else(
|
let message = found_word.map_or_else(
|
||||||
|| format!("expected one of {joined}"),
|
|| format!("expected one of {joined}"),
|
||||||
|w| format!("expected one of {joined}, found `{w}`"),
|
|w| format!("expected one of {joined}, found `{w}`"),
|
||||||
@@ -1034,19 +1028,22 @@ mod tests {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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
|
expected
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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
|
expected
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_relationship_repeated_clause_errors() {
|
fn add_relationship_repeated_clause_errors() {
|
||||||
let e =
|
let e = err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
|
||||||
err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
|
|
||||||
match e {
|
match e {
|
||||||
ParseError::Invalid { message, .. } => {
|
ParseError::Invalid { message, .. } => {
|
||||||
assert!(message.contains("specified twice"), "{message}");
|
assert!(message.contains("specified twice"), "{message}");
|
||||||
@@ -1073,7 +1070,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn add_relationship_with_name_actions_and_flag() {
|
fn add_relationship_with_name_actions_and_flag() {
|
||||||
assert_eq!(
|
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(
|
rel(
|
||||||
Some("cust_orders"),
|
Some("cust_orders"),
|
||||||
("Customers", "Id"),
|
("Customers", "Id"),
|
||||||
@@ -1300,10 +1299,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn advanced_ambiguous_update_routes_to_sql() {
|
fn advanced_ambiguous_update_routes_to_sql() {
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
parse_command_in_mode(
|
parse_command_in_mode("update Orders set total = 0 where id = 1", Mode::Advanced,),
|
||||||
"update Orders set total = 0 where id = 1",
|
|
||||||
Mode::Advanced,
|
|
||||||
),
|
|
||||||
Ok(Command::SqlUpdate { .. })
|
Ok(Command::SqlUpdate { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1399,10 +1395,7 @@ mod tests {
|
|||||||
// in advanced mode)" pointer is added at the hint layer
|
// in advanced mode)" pointer is added at the hint layer
|
||||||
// (input_render), not in the parsed command/error here.
|
// (input_render), not in the parsed command/error here.
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
parse_command_in_mode(
|
parse_command_in_mode("delete from Orders where id = 1 returning *", Mode::Simple,),
|
||||||
"delete from Orders where id = 1 returning *",
|
|
||||||
Mode::Simple,
|
|
||||||
),
|
|
||||||
Err(ParseError::Invalid { .. })
|
Err(ParseError::Invalid { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -9,8 +9,7 @@ use rand::RngExt;
|
|||||||
|
|
||||||
/// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded
|
/// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded
|
||||||
/// because they are easily confused in print.
|
/// because they are easily confused in print.
|
||||||
const ALPHABET: &[u8; 58] =
|
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||||
b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
||||||
|
|
||||||
const DEFAULT_LEN: usize = 10;
|
const DEFAULT_LEN: usize = 10;
|
||||||
|
|
||||||
|
|||||||
@@ -43,29 +43,9 @@
|
|||||||
/// - **Broader scalars:** `date`, `datetime`, `hex`, `ifnull`,
|
/// - **Broader scalars:** `date`, `datetime`, `hex`, `ifnull`,
|
||||||
/// `instr`, `nullif`, `random`, `replace`, `strftime`, `typeof`.
|
/// `instr`, `nullif`, `random`, `replace`, `strftime`, `typeof`.
|
||||||
pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
|
pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
|
||||||
"abs",
|
"abs", "avg", "coalesce", "count", "date", "datetime", "hex", "ifnull", "instr", "length",
|
||||||
"avg",
|
"lower", "max", "min", "nullif", "random", "replace", "round", "strftime", "substr", "sum",
|
||||||
"coalesce",
|
"trim", "typeof", "upper",
|
||||||
"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
|
/// Whether `partial` is a case-insensitive prefix of at least one
|
||||||
@@ -80,9 +60,7 @@ pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_known_function_prefix(partial: &str) -> bool {
|
pub fn is_known_function_prefix(partial: &str) -> bool {
|
||||||
let lowered = partial.to_lowercase();
|
let lowered = partial.to_lowercase();
|
||||||
KNOWN_SQL_FUNCTIONS
|
KNOWN_SQL_FUNCTIONS.iter().any(|f| f.starts_with(&lowered))
|
||||||
.iter()
|
|
||||||
.any(|f| f.starts_with(&lowered))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+19
-40
@@ -1,7 +1,8 @@
|
|||||||
//! User-facing column types and their mapping to SQLite STRICT.
|
//! User-facing column types and their mapping to SQLite STRICT.
|
||||||
//!
|
//!
|
||||||
//! Implements the full ten-type vocabulary committed to in
|
//! Implements the nine-type vocabulary committed to in ADR-0005
|
||||||
//! ADR-0005. Storage choices for the text-backed types
|
//! (ten as originally decided; `blob` dropped by Amendment 2).
|
||||||
|
//! Storage choices for the text-backed types
|
||||||
//! (`decimal`, `date`, `datetime`) preserve precision and ISO
|
//! (`decimal`, `date`, `datetime`) preserve precision and ISO
|
||||||
//! readability; comparisons rely on lexicographic ordering or
|
//! readability; comparisons rely on lexicographic ordering or
|
||||||
//! explicit casts at query time, which is acceptable for a
|
//! explicit casts at query time, which is acceptable for a
|
||||||
@@ -27,8 +28,6 @@ pub enum Type {
|
|||||||
Date,
|
Date,
|
||||||
/// ISO 8601 datetime stored as `YYYY-MM-DDTHH:MM:SS[.fff][Z]` (TEXT).
|
/// ISO 8601 datetime stored as `YYYY-MM-DDTHH:MM:SS[.fff][Z]` (TEXT).
|
||||||
DateTime,
|
DateTime,
|
||||||
/// Arbitrary binary data.
|
|
||||||
Blob,
|
|
||||||
/// Auto-incrementing integer; intended as a default primary key.
|
/// Auto-incrementing integer; intended as a default primary key.
|
||||||
Serial,
|
Serial,
|
||||||
/// 10–12 character base58 random identifier (no ambiguous chars).
|
/// 10–12 character base58 random identifier (no ambiguous chars).
|
||||||
@@ -47,7 +46,6 @@ impl Type {
|
|||||||
Self::Bool => "bool",
|
Self::Bool => "bool",
|
||||||
Self::Date => "date",
|
Self::Date => "date",
|
||||||
Self::DateTime => "datetime",
|
Self::DateTime => "datetime",
|
||||||
Self::Blob => "blob",
|
|
||||||
Self::Serial => "serial",
|
Self::Serial => "serial",
|
||||||
Self::ShortId => "shortid",
|
Self::ShortId => "shortid",
|
||||||
}
|
}
|
||||||
@@ -59,14 +57,9 @@ impl Type {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn sqlite_strict_type(self) -> &'static str {
|
pub const fn sqlite_strict_type(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Text
|
Self::Text | Self::ShortId | Self::Decimal | Self::Date | Self::DateTime => "TEXT",
|
||||||
| Self::ShortId
|
|
||||||
| Self::Decimal
|
|
||||||
| Self::Date
|
|
||||||
| Self::DateTime => "TEXT",
|
|
||||||
Self::Int | Self::Serial | Self::Bool => "INTEGER",
|
Self::Int | Self::Serial | Self::Bool => "INTEGER",
|
||||||
Self::Real => "REAL",
|
Self::Real => "REAL",
|
||||||
Self::Blob => "BLOB",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +76,8 @@ impl Type {
|
|||||||
|
|
||||||
/// All types known in this iteration, in stable order.
|
/// All types known in this iteration, in stable order.
|
||||||
/// Ordering groups numeric types together, then boolean,
|
/// Ordering groups numeric types together, then boolean,
|
||||||
/// then temporal, then binary, then identity-flavoured
|
/// then temporal, then identity-flavoured auto-generated
|
||||||
/// auto-generated types.
|
/// types.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn all() -> &'static [Self] {
|
pub const fn all() -> &'static [Self] {
|
||||||
&[
|
&[
|
||||||
@@ -95,22 +88,18 @@ impl Type {
|
|||||||
Self::Bool,
|
Self::Bool,
|
||||||
Self::Date,
|
Self::Date,
|
||||||
Self::DateTime,
|
Self::DateTime,
|
||||||
Self::Blob,
|
|
||||||
Self::Serial,
|
Self::Serial,
|
||||||
Self::ShortId,
|
Self::ShortId,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True for the numeric types — `int`, `real`, `decimal`,
|
/// True for the numeric types — `int`, `real`, `decimal`,
|
||||||
/// `serial`. `bool` (stored 0/1) and the text- / blob-backed
|
/// `serial`. `bool` (stored 0/1) and the text-backed types
|
||||||
/// types are not numeric. Used to flag a `LIKE` text-pattern
|
/// are not numeric. Used to flag a `LIKE` text-pattern
|
||||||
/// match against a numeric column (ADR-0027, Amendment 1).
|
/// match against a numeric column (ADR-0027, Amendment 1).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn is_numeric(self) -> bool {
|
pub const fn is_numeric(self) -> bool {
|
||||||
matches!(
|
matches!(self, Self::Int | Self::Real | Self::Decimal | Self::Serial)
|
||||||
self,
|
|
||||||
Self::Int | Self::Real | Self::Decimal | Self::Serial
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The user-facing type that an FK column should use to
|
/// The user-facing type that an FK column should use to
|
||||||
@@ -136,7 +125,7 @@ impl Type {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a type name from the **advanced-mode SQL** type slot
|
/// Resolve a type name from the **advanced-mode SQL** type slot
|
||||||
/// (ADR-0035 §3). Accepts the ten playground keywords *and* the
|
/// (ADR-0035 §3). Accepts the nine playground keywords *and* the
|
||||||
/// standard-SQL aliases mapped onto them. Case-insensitive;
|
/// standard-SQL aliases mapped onto them. Case-insensitive;
|
||||||
/// internal whitespace is collapsed so `double precision` resolves
|
/// internal whitespace is collapsed so `double precision` resolves
|
||||||
/// regardless of spacing. Returns `None` for an unrecognised name —
|
/// regardless of spacing. Returns `None` for an unrecognised name —
|
||||||
@@ -144,7 +133,7 @@ impl Type {
|
|||||||
/// diagnostic.
|
/// diagnostic.
|
||||||
///
|
///
|
||||||
/// Deliberately distinct from [`FromStr`](std::str::FromStr), which
|
/// Deliberately distinct from [`FromStr`](std::str::FromStr), which
|
||||||
/// is the *simple-mode* parser and accepts only the ten keywords
|
/// is the *simple-mode* parser and accepts only the nine keywords
|
||||||
/// (no aliases), so simple mode teaches the playground's own
|
/// (no aliases), so simple mode teaches the playground's own
|
||||||
/// vocabulary. A length/precision argument (`varchar(255)`) is
|
/// vocabulary. A length/precision argument (`varchar(255)`) is
|
||||||
/// stripped by the grammar before the name reaches this resolver.
|
/// stripped by the grammar before the name reaches this resolver.
|
||||||
@@ -164,8 +153,7 @@ impl Type {
|
|||||||
"timestamp" => Some(Self::DateTime),
|
"timestamp" => Some(Self::DateTime),
|
||||||
"numeric" => Some(Self::Decimal),
|
"numeric" => Some(Self::Decimal),
|
||||||
"float" | "double precision" => Some(Self::Real),
|
"float" | "double precision" => Some(Self::Real),
|
||||||
"binary" | "varbinary" => Some(Self::Blob),
|
// Fall through to the canonical nine keywords.
|
||||||
// Fall through to the canonical ten keywords.
|
|
||||||
other => other.parse::<Self>().ok(),
|
other => other.parse::<Self>().ok(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,20 +265,19 @@ mod tests {
|
|||||||
Type::Bool,
|
Type::Bool,
|
||||||
Type::Date,
|
Type::Date,
|
||||||
Type::DateTime,
|
Type::DateTime,
|
||||||
Type::Blob,
|
|
||||||
] {
|
] {
|
||||||
assert_eq!(ty.fk_target_type(), ty);
|
assert_eq!(ty.fk_target_type(), ty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_ten_types_are_present_and_distinct() {
|
fn all_nine_types_are_present_and_distinct() {
|
||||||
let kws: Vec<&'static str> = Type::all().iter().map(|t| t.keyword()).collect();
|
let kws: Vec<&'static str> = Type::all().iter().map(|t| t.keyword()).collect();
|
||||||
assert_eq!(kws.len(), 10);
|
assert_eq!(kws.len(), 9);
|
||||||
let mut sorted = kws.clone();
|
let mut sorted = kws.clone();
|
||||||
sorted.sort_unstable();
|
sorted.sort_unstable();
|
||||||
sorted.dedup();
|
sorted.dedup();
|
||||||
assert_eq!(sorted.len(), 10, "keywords must be unique");
|
assert_eq!(sorted.len(), 9, "keywords must be unique");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -301,16 +288,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blob_type_maps_to_blob_storage() {
|
fn unknown_type_message_lists_all_nine() {
|
||||||
assert_eq!(Type::Blob.sqlite_strict_type(), "BLOB");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unknown_type_message_lists_all_ten() {
|
|
||||||
let err = "varchar".parse::<Type>().unwrap_err();
|
let err = "varchar".parse::<Type>().unwrap_err();
|
||||||
for kw in [
|
for kw in [
|
||||||
"text", "int", "real", "decimal", "bool", "date", "datetime", "blob", "serial",
|
"text", "int", "real", "decimal", "bool", "date", "datetime", "serial", "shortid",
|
||||||
"shortid",
|
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
err.expected.contains(kw),
|
err.expected.contains(kw),
|
||||||
@@ -321,12 +302,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- ADR-0035 §3: advanced-mode SQL type-name resolution ---
|
// --- ADR-0035 §3: advanced-mode SQL type-name resolution ---
|
||||||
// `from_sql_name` accepts the ten playground keywords *and* the
|
// `from_sql_name` accepts the nine playground keywords *and* the
|
||||||
// standard-SQL aliases. Simple-mode `FromStr` is unchanged (it
|
// standard-SQL aliases. Simple-mode `FromStr` is unchanged (it
|
||||||
// still rejects aliases — see `unknown_type_lists_expected_alternatives`).
|
// still rejects aliases — see `unknown_type_lists_expected_alternatives`).
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sql_resolver_accepts_the_ten_canonical_keywords() {
|
fn sql_resolver_accepts_the_nine_canonical_keywords() {
|
||||||
for &ty in Type::all() {
|
for &ty in Type::all() {
|
||||||
assert_eq!(Type::from_sql_name(ty.keyword()), Some(ty));
|
assert_eq!(Type::from_sql_name(ty.keyword()), Some(ty));
|
||||||
}
|
}
|
||||||
@@ -345,8 +326,6 @@ mod tests {
|
|||||||
("numeric", Type::Decimal),
|
("numeric", Type::Decimal),
|
||||||
("float", Type::Real),
|
("float", Type::Real),
|
||||||
("double precision", Type::Real),
|
("double precision", Type::Real),
|
||||||
("binary", Type::Blob),
|
|
||||||
("varbinary", Type::Blob),
|
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Type::from_sql_name(alias),
|
Type::from_sql_name(alias),
|
||||||
|
|||||||
+33
-25
@@ -101,10 +101,6 @@ impl Value {
|
|||||||
Type::Bool => self.bind_bool(column),
|
Type::Bool => self.bind_bool(column),
|
||||||
Type::Date => self.bind_date(column),
|
Type::Date => self.bind_date(column),
|
||||||
Type::DateTime => self.bind_datetime(column),
|
Type::DateTime => self.bind_datetime(column),
|
||||||
Type::Blob => Err(ValueError::Format {
|
|
||||||
column: column.to_string(),
|
|
||||||
message: "literal `blob` values are not supported in DSL yet".to_string(),
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,13 +125,14 @@ impl Value {
|
|||||||
|
|
||||||
fn bind_int(&self, column: &str, ty: Type) -> Result<Bound, ValueError> {
|
fn bind_int(&self, column: &str, ty: Type) -> Result<Bound, ValueError> {
|
||||||
match self {
|
match self {
|
||||||
Self::Number(n) => n
|
Self::Number(n) => {
|
||||||
.parse::<i64>()
|
n.parse::<i64>()
|
||||||
.map(Bound::Integer)
|
.map(Bound::Integer)
|
||||||
.map_err(|_| ValueError::Format {
|
.map_err(|_| ValueError::Format {
|
||||||
column: column.to_string(),
|
column: column.to_string(),
|
||||||
message: format!("`{n}` is not a valid {ty} (whole number expected)"),
|
message: format!("`{n}` is not a valid {ty} (whole number expected)"),
|
||||||
}),
|
})
|
||||||
|
}
|
||||||
other => Err(ValueError::TypeMismatch {
|
other => Err(ValueError::TypeMismatch {
|
||||||
column: column.to_string(),
|
column: column.to_string(),
|
||||||
expected_human: format!("a whole number for `{ty}`"),
|
expected_human: format!("a whole number for `{ty}`"),
|
||||||
@@ -241,9 +238,7 @@ pub(crate) fn validate_date(s: &str) -> Result<(), String> {
|
|||||||
// Expect YYYY-MM-DD: 10 chars, two dashes at fixed positions.
|
// Expect YYYY-MM-DD: 10 chars, two dashes at fixed positions.
|
||||||
let bytes = s.as_bytes();
|
let bytes = s.as_bytes();
|
||||||
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
|
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
|
||||||
return Err(format!(
|
return Err(format!("`{s}` is not a date in `YYYY-MM-DD` form"));
|
||||||
"`{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 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"))?;
|
let month = parse_digits(&s[5..7]).ok_or_else(|| format!("`{s}`: invalid month"))?;
|
||||||
@@ -272,7 +267,9 @@ pub(crate) fn validate_datetime(s: &str) -> Result<(), String> {
|
|||||||
validate_date(date_part)?;
|
validate_date(date_part)?;
|
||||||
let bytes = s.as_bytes();
|
let bytes = s.as_bytes();
|
||||||
if bytes[10] != b'T' {
|
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':' {
|
if bytes[13] != b':' || bytes[16] != b':' {
|
||||||
return Err(format!("`{s}`: time portion must be `HH:MM:SS`"));
|
return Err(format!("`{s}`: time portion must be `HH:MM:SS`"));
|
||||||
@@ -319,15 +316,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn null_binds_to_null_for_any_type() {
|
fn null_binds_to_null_for_any_type() {
|
||||||
for ty in Type::all() {
|
for ty in Type::all() {
|
||||||
// Skip blob — null still works there too.
|
|
||||||
assert_eq!(Value::Null.bind_for_column("c", *ty).unwrap(), Bound::Null);
|
assert_eq!(Value::Null.bind_for_column("c", *ty).unwrap(), Bound::Null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn integer_for_int_column() {
|
fn integer_for_int_column() {
|
||||||
assert_eq!(n("42").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(42));
|
assert_eq!(
|
||||||
assert_eq!(n("-7").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(-7));
|
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]
|
#[test]
|
||||||
@@ -355,7 +357,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shortid_validation_runs_on_text_for_shortid_column() {
|
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 { .. }));
|
assert!(matches!(err, ValueError::Format { .. }));
|
||||||
|
|
||||||
// Well-formed shortid binds fine.
|
// Well-formed shortid binds fine.
|
||||||
@@ -367,8 +371,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bool_for_bool_column_maps_to_zero_or_one() {
|
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!(
|
||||||
assert_eq!(Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(0));
|
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]
|
#[test]
|
||||||
@@ -377,13 +387,17 @@ mod tests {
|
|||||||
t("2025-01-15").bind_for_column("c", Type::Date).unwrap(),
|
t("2025-01-15").bind_for_column("c", Type::Date).unwrap(),
|
||||||
Bound::Text("2025-01-15".to_string())
|
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 { .. }));
|
assert!(matches!(err, ValueError::Format { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn date_range_check() {
|
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")));
|
assert!(matches!(err, ValueError::Format { message, .. } if message.contains("month")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,10 +424,4 @@ mod tests {
|
|||||||
let err = n("3..14").bind_for_column("c", Type::Decimal).unwrap_err();
|
let err = n("3..14").bind_for_column("c", Type::Decimal).unwrap_err();
|
||||||
assert!(matches!(err, ValueError::Format { .. }));
|
assert!(matches!(err, ValueError::Format { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blob_inserts_are_explicitly_unsupported_for_now() {
|
|
||||||
let err = t("0xdead").bind_for_column("c", Type::Blob).unwrap_err();
|
|
||||||
assert!(matches!(err, ValueError::Format { message, .. } if message.contains("blob")));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,18 @@ pub struct WalkContext<'a> {
|
|||||||
/// resolver reads this directly instead of inferring the
|
/// resolver reads this directly instead of inferring the
|
||||||
/// slot kind from the shape of the expected set.
|
/// slot kind from the shape of the expected set.
|
||||||
pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
|
pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
|
||||||
|
/// Byte spans of the clause-concept regions the walk passed
|
||||||
|
/// through (issue #37 / ADR clause-concept-hints, D1).
|
||||||
|
///
|
||||||
|
/// A `Node::Concept { topic, inner }` wrapper pushes one entry
|
||||||
|
/// per *committed* inner walk (Matched / Incomplete / Failed —
|
||||||
|
/// never on NoMatch). Unlike `pending_hint_mode`, this is
|
||||||
|
/// **append-only and never cleared on match**, so a fully-typed
|
||||||
|
/// clause keeps its span — that is what lets the F1
|
||||||
|
/// clause-concept resolver report a concept for a cursor parked
|
||||||
|
/// *inside* already-typed clause text, not only at the slot
|
||||||
|
/// boundary.
|
||||||
|
pub concept_spans: Vec<ConceptSpan>,
|
||||||
/// An `IntroProse` hint captured from an *optional* slot that
|
/// An `IntroProse` hint captured from an *optional* slot that
|
||||||
/// the walk skipped (issue #26). Unlike `pending_hint_mode`
|
/// the walk skipped (issue #26). Unlike `pending_hint_mode`
|
||||||
/// (cleared on the very next match — including the empty match
|
/// (cleared on the very next match — including the empty match
|
||||||
@@ -227,6 +239,19 @@ pub struct PendingCteHarvest {
|
|||||||
pub cte_name_span: (usize, usize),
|
pub cte_name_span: (usize, usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A clause-concept region recorded during a walk (issue #37).
|
||||||
|
///
|
||||||
|
/// `topic` is the `hint.concept.<topic>` catalogue key; `[start,
|
||||||
|
/// end]` is the inclusive byte range the clause covered as typed so
|
||||||
|
/// far (`end` is the matched end on a full match, or the stop
|
||||||
|
/// position on an incomplete / failed inner).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ConceptSpan {
|
||||||
|
pub topic: &'static str,
|
||||||
|
pub start: usize,
|
||||||
|
pub end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
impl<'a> WalkContext<'a> {
|
impl<'a> WalkContext<'a> {
|
||||||
/// Schemaless walk context — the legacy default used by
|
/// Schemaless walk context — the legacy default used by
|
||||||
/// pre-Phase-D callers and tests that don't care about
|
/// pre-Phase-D callers and tests that don't care about
|
||||||
@@ -243,6 +268,7 @@ impl<'a> WalkContext<'a> {
|
|||||||
pending_value_type: None,
|
pending_value_type: None,
|
||||||
pending_value_column: None,
|
pending_value_column: None,
|
||||||
pending_hint_mode: None,
|
pending_hint_mode: None,
|
||||||
|
concept_spans: Vec::new(),
|
||||||
surviving_intro_hint: None,
|
surviving_intro_hint: None,
|
||||||
user_listed_columns: None,
|
user_listed_columns: None,
|
||||||
subgrammar_depth: 0,
|
subgrammar_depth: 0,
|
||||||
@@ -266,6 +292,7 @@ impl<'a> WalkContext<'a> {
|
|||||||
pending_value_type: None,
|
pending_value_type: None,
|
||||||
pending_value_column: None,
|
pending_value_column: None,
|
||||||
pending_hint_mode: None,
|
pending_hint_mode: None,
|
||||||
|
concept_spans: Vec::new(),
|
||||||
surviving_intro_hint: None,
|
surviving_intro_hint: None,
|
||||||
user_listed_columns: None,
|
user_listed_columns: None,
|
||||||
subgrammar_depth: 0,
|
subgrammar_depth: 0,
|
||||||
|
|||||||
+141
-174
@@ -28,12 +28,10 @@ use crate::completion::TableColumn;
|
|||||||
use crate::dsl::grammar::{HighlightClass, Node, ValidationError};
|
use crate::dsl::grammar::{HighlightClass, Node, ValidationError};
|
||||||
use crate::dsl::walker::context::WalkContext;
|
use crate::dsl::walker::context::WalkContext;
|
||||||
use crate::dsl::walker::lex_helpers::{
|
use crate::dsl::walker::lex_helpers::{
|
||||||
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
|
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
|
||||||
consume_string_literal, skip_whitespace,
|
skip_whitespace,
|
||||||
};
|
|
||||||
use crate::dsl::walker::outcome::{
|
|
||||||
ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath,
|
|
||||||
};
|
};
|
||||||
|
use crate::dsl::walker::outcome::{ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath};
|
||||||
|
|
||||||
/// Maximum nesting of `Node::Subgrammar` frames (ADR-0026 §1).
|
/// 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`,
|
/// Resolve a `DynamicSubgrammar` factory to a `&'static Node`,
|
||||||
/// reusing a previously-leaked Node when the factory's inputs
|
/// reusing a previously-leaked Node when the factory's inputs
|
||||||
/// match a cached entry.
|
/// match a cached entry.
|
||||||
fn resolve_dynamic(
|
fn resolve_dynamic(factory: fn(&WalkContext) -> Node, ctx: &WalkContext) -> &'static Node {
|
||||||
factory: fn(&WalkContext) -> Node,
|
|
||||||
ctx: &WalkContext,
|
|
||||||
) -> &'static Node {
|
|
||||||
let key = DynamicKey {
|
let key = DynamicKey {
|
||||||
factory: factory as usize,
|
factory: factory as usize,
|
||||||
current_table_columns: ctx.current_table_columns.clone(),
|
current_table_columns: ctx.current_table_columns.clone(),
|
||||||
@@ -123,10 +118,7 @@ pub enum NodeWalkResult {
|
|||||||
expected: Vec<Expectation>,
|
expected: Vec<Expectation>,
|
||||||
},
|
},
|
||||||
/// Committed and hit a hard mismatch or validator failure.
|
/// Committed and hit a hard mismatch or validator failure.
|
||||||
Failed {
|
Failed { position: usize, kind: FailureKind },
|
||||||
position: usize,
|
|
||||||
kind: FailureKind,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn matched(end: usize) -> NodeWalkResult {
|
const fn matched(end: usize) -> NodeWalkResult {
|
||||||
@@ -218,9 +210,7 @@ fn walk_node_inner(
|
|||||||
kind: FailureKind::Mismatch { expected: vec![] },
|
kind: FailureKind::Mismatch { expected: vec![] },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Node::Subgrammar(inner) => {
|
Node::Subgrammar(inner) => walk_subgrammar(source, pos, inner, ctx, path, per_byte),
|
||||||
walk_subgrammar(source, pos, inner, ctx, path, per_byte)
|
|
||||||
}
|
|
||||||
Node::ScopedSubgrammar(inner) => {
|
Node::ScopedSubgrammar(inner) => {
|
||||||
walk_scoped_subgrammar(source, pos, inner, ctx, path, per_byte)
|
walk_scoped_subgrammar(source, pos, inner, ctx, path, per_byte)
|
||||||
}
|
}
|
||||||
@@ -247,8 +237,7 @@ fn walk_node_inner(
|
|||||||
// DynamicSubgrammar wrapper that delegates to the
|
// DynamicSubgrammar wrapper that delegates to the
|
||||||
// memoized `column_value_list`), so the per-walk
|
// memoized `column_value_list`), so the per-walk
|
||||||
// leak is a few bytes, not a whole typed tree.
|
// leak is a few bytes, not a whole typed tree.
|
||||||
let resolved: &'static Node =
|
let resolved: &'static Node = Box::leak(Box::new(factory(ctx, source, pos)));
|
||||||
Box::leak(Box::new(factory(ctx, source, pos)));
|
|
||||||
walk_node(source, pos, resolved, ctx, path, per_byte)
|
walk_node(source, pos, resolved, ctx, path, per_byte)
|
||||||
}
|
}
|
||||||
Node::SetColumn(col) => {
|
Node::SetColumn(col) => {
|
||||||
@@ -262,7 +251,10 @@ fn walk_node_inner(
|
|||||||
let col: &crate::completion::TableColumn = col;
|
let col: &crate::completion::TableColumn = col;
|
||||||
ctx.current_column = Some(col.clone());
|
ctx.current_column = Some(col.clone());
|
||||||
ctx.pending_value_column = Some(col.name.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 {
|
Node::TypedValueSlot {
|
||||||
ty,
|
ty,
|
||||||
@@ -298,6 +290,33 @@ fn walk_node_inner(
|
|||||||
ctx.pending_hint_mode = Some(*mode);
|
ctx.pending_hint_mode = Some(*mode);
|
||||||
walk_node(source, pos, inner, ctx, path, per_byte)
|
walk_node(source, pos, inner, ctx, path, per_byte)
|
||||||
}
|
}
|
||||||
|
Node::Concept { topic, inner } => {
|
||||||
|
// Issue #37 / ADR clause-concept-hints D1. Walk the
|
||||||
|
// inner clause, then record its covered byte span in
|
||||||
|
// `ctx.concept_spans` iff the inner *committed* — i.e.
|
||||||
|
// matched, ran out mid-clause (Incomplete), or hit a
|
||||||
|
// hard failure after engaging (Failed). On `NoMatch`
|
||||||
|
// the node never engaged (e.g. a `Choice` tried this
|
||||||
|
// branch and it didn't apply), so record nothing — no
|
||||||
|
// stale span. `start` is the post-whitespace position
|
||||||
|
// the walk wrapper already resolved.
|
||||||
|
let result = walk_node(source, pos, inner, ctx, path, per_byte);
|
||||||
|
let end = match &result {
|
||||||
|
NodeWalkResult::Matched { end, .. } => Some(*end),
|
||||||
|
NodeWalkResult::Incomplete { position, .. }
|
||||||
|
| NodeWalkResult::Failed { position, .. } => Some(*position),
|
||||||
|
NodeWalkResult::NoMatch { .. } => None,
|
||||||
|
};
|
||||||
|
if let Some(end) = end {
|
||||||
|
ctx.concept_spans
|
||||||
|
.push(crate::dsl::walker::context::ConceptSpan {
|
||||||
|
topic,
|
||||||
|
start: pos,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
Node::Flag(name) => walk_flag(source, pos, name, path, per_byte),
|
Node::Flag(name) => walk_flag(source, pos, name, path, per_byte),
|
||||||
Node::Repeated {
|
Node::Repeated {
|
||||||
inner,
|
inner,
|
||||||
@@ -342,7 +361,10 @@ fn walk_word(
|
|||||||
// Amendment 4). Plain keywords leave it `None`.
|
// Amendment 4). Plain keywords leave it `None`.
|
||||||
class: word.highlight_override.unwrap_or(HighlightClass::Keyword),
|
class: word.highlight_override.unwrap_or(HighlightClass::Keyword),
|
||||||
});
|
});
|
||||||
NodeWalkResult::Matched { end, skipped: Vec::new() }
|
NodeWalkResult::Matched {
|
||||||
|
end,
|
||||||
|
skipped: Vec::new(),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
NodeWalkResult::NoMatch {
|
NodeWalkResult::NoMatch {
|
||||||
position,
|
position,
|
||||||
@@ -477,9 +499,7 @@ fn walk_ident(
|
|||||||
// ScopedSubgrammar (which is structurally guaranteed to be
|
// ScopedSubgrammar (which is structurally guaranteed to be
|
||||||
// the CTE body — no intervening scoped subgrammar in CTE
|
// the CTE body — no intervening scoped subgrammar in CTE
|
||||||
// syntax) runs the harvest at body-frame exit.
|
// syntax) runs the harvest at body-frame exit.
|
||||||
if writes_cte_name
|
if writes_cte_name && let Some(frame) = ctx.from_scope_stack.last_mut() {
|
||||||
&& let Some(frame) = ctx.from_scope_stack.last_mut()
|
|
||||||
{
|
|
||||||
frame
|
frame
|
||||||
.cte_bindings
|
.cte_bindings
|
||||||
.push(crate::dsl::walker::context::CteBinding {
|
.push(crate::dsl::walker::context::CteBinding {
|
||||||
@@ -487,8 +507,7 @@ fn walk_ident(
|
|||||||
columns: Vec::new(),
|
columns: Vec::new(),
|
||||||
});
|
});
|
||||||
let placeholder_index = frame.cte_bindings.len() - 1;
|
let placeholder_index = frame.cte_bindings.len() - 1;
|
||||||
ctx.pending_cte_harvest =
|
ctx.pending_cte_harvest = Some(crate::dsl::walker::context::PendingCteHarvest {
|
||||||
Some(crate::dsl::walker::context::PendingCteHarvest {
|
|
||||||
placeholder_index,
|
placeholder_index,
|
||||||
col_list: Vec::new(),
|
col_list: Vec::new(),
|
||||||
cte_name: text.clone(),
|
cte_name: text.clone(),
|
||||||
@@ -507,9 +526,7 @@ fn walk_ident(
|
|||||||
}
|
}
|
||||||
// ADR-0032 §10.4: projection-list alias accumulator for
|
// ADR-0032 §10.4: projection-list alias accumulator for
|
||||||
// ORDER BY completion candidates.
|
// ORDER BY completion candidates.
|
||||||
if writes_projection_alias
|
if writes_projection_alias && let Some(frame) = ctx.from_scope_stack.last_mut() {
|
||||||
&& let Some(frame) = ctx.from_scope_stack.last_mut()
|
|
||||||
{
|
|
||||||
frame.projection_aliases.push(text.clone());
|
frame.projection_aliases.push(text.clone());
|
||||||
}
|
}
|
||||||
if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
|
if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
|
||||||
@@ -529,9 +546,7 @@ fn walk_ident(
|
|||||||
.map(|c| c.name.clone())
|
.map(|c| c.name.clone())
|
||||||
.or_else(|| Some(text.clone()));
|
.or_else(|| Some(text.clone()));
|
||||||
}
|
}
|
||||||
if writes_user_listed_column
|
if writes_user_listed_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
|
||||||
&& matches!(src, crate::dsl::grammar::IdentSource::Columns)
|
|
||||||
{
|
|
||||||
// Form A: `insert into <T> (col1, col2, …)`. Append the
|
// Form A: `insert into <T> (col1, col2, …)`. Append the
|
||||||
// matched column name to user_listed_columns so the
|
// matched column name to user_listed_columns so the
|
||||||
// inner `values (…)` slot list mirrors the user's
|
// inner `values (…)` slot list mirrors the user's
|
||||||
@@ -564,7 +579,10 @@ fn walk_ident(
|
|||||||
// (issue #8 / ADR-0022 Amendment 4).
|
// (issue #8 / ADR-0022 Amendment 4).
|
||||||
class: highlight_override.unwrap_or(HighlightClass::Identifier),
|
class: highlight_override.unwrap_or(HighlightClass::Identifier),
|
||||||
});
|
});
|
||||||
NodeWalkResult::Matched { end, skipped: Vec::new() }
|
NodeWalkResult::Matched {
|
||||||
|
end,
|
||||||
|
skipped: Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn walk_string_lit(
|
fn walk_string_lit(
|
||||||
@@ -648,7 +666,10 @@ fn walk_literal(
|
|||||||
end,
|
end,
|
||||||
class,
|
class,
|
||||||
});
|
});
|
||||||
NodeWalkResult::Matched { end, skipped: Vec::new() }
|
NodeWalkResult::Matched {
|
||||||
|
end,
|
||||||
|
skipped: Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn walk_number_lit(
|
fn walk_number_lit(
|
||||||
@@ -683,7 +704,10 @@ fn walk_number_lit(
|
|||||||
end,
|
end,
|
||||||
class: HighlightClass::Number,
|
class: HighlightClass::Number,
|
||||||
});
|
});
|
||||||
NodeWalkResult::Matched { end, skipped: Vec::new() }
|
NodeWalkResult::Matched {
|
||||||
|
end,
|
||||||
|
skipped: Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn walk_flag(
|
fn walk_flag(
|
||||||
@@ -717,7 +741,10 @@ fn walk_flag(
|
|||||||
end,
|
end,
|
||||||
class: HighlightClass::Flag,
|
class: HighlightClass::Flag,
|
||||||
});
|
});
|
||||||
NodeWalkResult::Matched { end, skipped: Vec::new() }
|
NodeWalkResult::Matched {
|
||||||
|
end,
|
||||||
|
skipped: Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -784,7 +811,10 @@ fn walk_repeated(
|
|||||||
count += 1;
|
count += 1;
|
||||||
last_item_skipped = skipped;
|
last_item_skipped = skipped;
|
||||||
}
|
}
|
||||||
NodeWalkResult::NoMatch { expected, position: inner_pos } => {
|
NodeWalkResult::NoMatch {
|
||||||
|
expected,
|
||||||
|
position: inner_pos,
|
||||||
|
} => {
|
||||||
// Mid-typing-the-next-item recovery: if the
|
// Mid-typing-the-next-item recovery: if the
|
||||||
// separator just consumed and the inner failed
|
// separator just consumed and the inner failed
|
||||||
// at EOF, the user is partway through typing the
|
// at EOF, the user is partway through typing the
|
||||||
@@ -860,7 +890,10 @@ fn walk_bare_path(
|
|||||||
end,
|
end,
|
||||||
class: HighlightClass::String,
|
class: HighlightClass::String,
|
||||||
});
|
});
|
||||||
NodeWalkResult::Matched { end, skipped: Vec::new() }
|
NodeWalkResult::Matched {
|
||||||
|
end,
|
||||||
|
skipped: Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn walk_choice(
|
fn walk_choice(
|
||||||
@@ -1031,7 +1064,10 @@ fn walk_optional(
|
|||||||
skipped: expected,
|
skipped: expected,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NodeWalkResult::Incomplete { position: p, expected } if !inner_committed => {
|
NodeWalkResult::Incomplete {
|
||||||
|
position: p,
|
||||||
|
expected,
|
||||||
|
} if !inner_committed => {
|
||||||
// Inner reported Incomplete without consuming
|
// Inner reported Incomplete without consuming
|
||||||
// anything — same as NoMatch from the user's
|
// anything — same as NoMatch from the user's
|
||||||
// perspective. Roll back and skip.
|
// perspective. Roll back and skip.
|
||||||
@@ -1156,9 +1192,7 @@ fn walk_scoped_subgrammar(
|
|||||||
// walks that NoMatch / Incomplete / Fail leave the placeholder
|
// walks that NoMatch / Incomplete / Fail leave the placeholder
|
||||||
// empty (the outer-frame state is also discarded in the
|
// empty (the outer-frame state is also discarded in the
|
||||||
// speculative path, so this is correct).
|
// speculative path, so this is correct).
|
||||||
if let (Some(req), NodeWalkResult::Matched { end, .. }) =
|
if let (Some(req), NodeWalkResult::Matched { end, .. }) = (pending_cte, &result) {
|
||||||
(pending_cte, &result)
|
|
||||||
{
|
|
||||||
run_cte_harvest(ctx, path, source, pos, *end, &req);
|
run_cte_harvest(ctx, path, source, pos, *end, &req);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1240,9 +1274,8 @@ fn run_cte_harvest(
|
|||||||
select_idx = Some(i + 1); // start of projection list
|
select_idx = Some(i + 1); // start of projection list
|
||||||
}
|
}
|
||||||
MatchedKind::Word(
|
MatchedKind::Word(
|
||||||
"from" | "where" | "group" | "having" | "order"
|
"from" | "where" | "group" | "having" | "order" | "limit" | "offset" | "union"
|
||||||
| "limit" | "offset" | "union" | "intersect"
|
| "intersect" | "except",
|
||||||
| "except",
|
|
||||||
) if select_idx.is_some() => {
|
) if select_idx.is_some() => {
|
||||||
end_idx = i;
|
end_idx = i;
|
||||||
break;
|
break;
|
||||||
@@ -1281,12 +1314,7 @@ fn run_cte_harvest(
|
|||||||
// Classify each projection item per ADR-0032 §10.3.
|
// Classify each projection item per ADR-0032 §10.3.
|
||||||
let mut derived: Vec<CteColumn> = Vec::new();
|
let mut derived: Vec<CteColumn> = Vec::new();
|
||||||
for slice in item_slices {
|
for slice in item_slices {
|
||||||
classify_projection_item(
|
classify_projection_item(slice, body_frame, &ctx.from_scope_stack, &mut derived);
|
||||||
slice,
|
|
||||||
body_frame,
|
|
||||||
&ctx.from_scope_stack,
|
|
||||||
&mut derived,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply (c1, c2, …) positional rename if provided. Types
|
// Apply (c1, c2, …) positional rename if provided. Types
|
||||||
@@ -1339,8 +1367,7 @@ fn run_cte_harvest(
|
|||||||
let stack_len = ctx.from_scope_stack.len();
|
let stack_len = ctx.from_scope_stack.len();
|
||||||
if stack_len >= 2
|
if stack_len >= 2
|
||||||
&& let Some(outer) = ctx.from_scope_stack.get_mut(stack_len - 2)
|
&& let Some(outer) = ctx.from_scope_stack.get_mut(stack_len - 2)
|
||||||
&& let Some(placeholder) =
|
&& let Some(placeholder) = outer.cte_bindings.get_mut(req.placeholder_index)
|
||||||
outer.cte_bindings.get_mut(req.placeholder_index)
|
|
||||||
{
|
{
|
||||||
placeholder.columns = derived;
|
placeholder.columns = derived;
|
||||||
}
|
}
|
||||||
@@ -1368,9 +1395,7 @@ fn classify_projection_item(
|
|||||||
// empty because it wasn't a base-table lookup), resolve
|
// empty because it wasn't a base-table lookup), resolve
|
||||||
// through to the in-scope CteBinding so nested CTEs project
|
// through to the in-scope CteBinding so nested CTEs project
|
||||||
// correctly.
|
// correctly.
|
||||||
if expr_slice.len() == 1
|
if expr_slice.len() == 1 && matches!(expr_slice[0].kind, MatchedKind::Punct('*')) {
|
||||||
&& matches!(expr_slice[0].kind, MatchedKind::Punct('*'))
|
|
||||||
{
|
|
||||||
for binding in &body_frame.from_scope {
|
for binding in &body_frame.from_scope {
|
||||||
for col in expand_binding(binding, scope_stack) {
|
for col in expand_binding(binding, scope_stack) {
|
||||||
out.push(col);
|
out.push(col);
|
||||||
@@ -1383,7 +1408,10 @@ fn classify_projection_item(
|
|||||||
if expr_slice.len() == 3
|
if expr_slice.len() == 3
|
||||||
&& matches!(
|
&& matches!(
|
||||||
expr_slice[0].kind,
|
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[1].kind, MatchedKind::Punct('.'))
|
||||||
&& matches!(expr_slice[2].kind, MatchedKind::Punct('*'))
|
&& matches!(expr_slice[2].kind, MatchedKind::Punct('*'))
|
||||||
@@ -1413,11 +1441,7 @@ fn classify_projection_item(
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
let col_text = &expr_slice[0].text;
|
let col_text = &expr_slice[0].text;
|
||||||
let resolved_type = resolve_bare_column_type_in_frame(
|
let resolved_type = resolve_bare_column_type_in_frame(body_frame, scope_stack, col_text);
|
||||||
body_frame,
|
|
||||||
scope_stack,
|
|
||||||
col_text,
|
|
||||||
);
|
|
||||||
let name = alias.unwrap_or_else(|| col_text.clone());
|
let name = alias.unwrap_or_else(|| col_text.clone());
|
||||||
out.push(CteColumn {
|
out.push(CteColumn {
|
||||||
name: Some(name),
|
name: Some(name),
|
||||||
@@ -1447,12 +1471,7 @@ fn classify_projection_item(
|
|||||||
{
|
{
|
||||||
let qual = &expr_slice[0].text;
|
let qual = &expr_slice[0].text;
|
||||||
let col_text = &expr_slice[2].text;
|
let col_text = &expr_slice[2].text;
|
||||||
let resolved_type = resolve_qualified_column_type(
|
let resolved_type = resolve_qualified_column_type(body_frame, scope_stack, qual, col_text);
|
||||||
body_frame,
|
|
||||||
scope_stack,
|
|
||||||
qual,
|
|
||||||
col_text,
|
|
||||||
);
|
|
||||||
let name = alias.unwrap_or_else(|| col_text.clone());
|
let name = alias.unwrap_or_else(|| col_text.clone());
|
||||||
out.push(CteColumn {
|
out.push(CteColumn {
|
||||||
name: Some(name),
|
name: Some(name),
|
||||||
@@ -1493,16 +1512,8 @@ fn strip_trailing_alias<'a>(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// Optional preceding `AS` keyword.
|
// Optional preceding `AS` keyword.
|
||||||
if slice.len() >= 2
|
if slice.len() >= 2 && matches!(slice[slice.len() - 2].kind, MatchedKind::Word("as")) {
|
||||||
&& matches!(
|
return (&slice[..slice.len() - 2], Some(last.text.clone()));
|
||||||
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()));
|
return (&slice[..slice.len() - 1], Some(last.text.clone()));
|
||||||
}
|
}
|
||||||
@@ -1613,8 +1624,8 @@ fn merge_expected(dst: &mut Vec<Expectation>, src: Vec<Expectation>) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult,
|
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult, resolve_dynamic,
|
||||||
resolve_dynamic, walk_node,
|
walk_node,
|
||||||
};
|
};
|
||||||
use crate::dsl::grammar::{Node, Word};
|
use crate::dsl::grammar::{Node, Word};
|
||||||
use crate::dsl::walker::context::WalkContext;
|
use crate::dsl::walker::context::WalkContext;
|
||||||
@@ -1629,18 +1640,14 @@ mod tests {
|
|||||||
Node::Subgrammar(&NESTED),
|
Node::Subgrammar(&NESTED),
|
||||||
Node::Punct(')'),
|
Node::Punct(')'),
|
||||||
];
|
];
|
||||||
static NESTED_CHOICES: &[Node] = &[
|
static NESTED_CHOICES: &[Node] = &[Node::Seq(NESTED_GROUP), Node::Word(Word::keyword("x"))];
|
||||||
Node::Seq(NESTED_GROUP),
|
|
||||||
Node::Word(Word::keyword("x")),
|
|
||||||
];
|
|
||||||
static NESTED: Node = Node::Choice(NESTED_CHOICES);
|
static NESTED: Node = Node::Choice(NESTED_CHOICES);
|
||||||
|
|
||||||
fn walk_nested(input: &str) -> NodeWalkResult {
|
fn walk_nested(input: &str) -> NodeWalkResult {
|
||||||
let mut ctx = WalkContext::new();
|
let mut ctx = WalkContext::new();
|
||||||
let mut path = MatchedPath::new();
|
let mut path = MatchedPath::new();
|
||||||
let mut per_byte = Vec::new();
|
let mut per_byte = Vec::new();
|
||||||
let result =
|
let result = walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
|
||||||
walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ctx.subgrammar_depth, 0,
|
ctx.subgrammar_depth, 0,
|
||||||
"subgrammar_depth must be restored to 0 after the walk",
|
"subgrammar_depth must be restored to 0 after the walk",
|
||||||
@@ -1726,14 +1733,8 @@ mod tests {
|
|||||||
fn resolve_dynamic_cache_is_populated() {
|
fn resolve_dynamic_cache_is_populated() {
|
||||||
let ctx = WalkContext::new();
|
let ctx = WalkContext::new();
|
||||||
let _ = resolve_dynamic(const_factory, &ctx);
|
let _ = resolve_dynamic(const_factory, &ctx);
|
||||||
let populated = !DYNAMIC_CACHE
|
let populated = !DYNAMIC_CACHE.lock().expect("cache lock").is_empty();
|
||||||
.lock()
|
assert!(populated, "resolve_dynamic should populate the memo cache",);
|
||||||
.expect("cache lock")
|
|
||||||
.is_empty();
|
|
||||||
assert!(
|
|
||||||
populated,
|
|
||||||
"resolve_dynamic should populate the memo cache",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- ScopedSubgrammar (ADR-0032 §10.2) -----------------------
|
// ---- ScopedSubgrammar (ADR-0032 §10.2) -----------------------
|
||||||
@@ -1758,14 +1759,7 @@ mod tests {
|
|||||||
let mut path = MatchedPath::new();
|
let mut path = MatchedPath::new();
|
||||||
let mut per_byte = Vec::new();
|
let mut per_byte = Vec::new();
|
||||||
let baseline_frames = ctx.from_scope_stack.len();
|
let baseline_frames = ctx.from_scope_stack.len();
|
||||||
let result = walk_node(
|
let result = walk_node(input, 0, &SCOPED_NESTED, &mut ctx, &mut path, &mut per_byte);
|
||||||
input,
|
|
||||||
0,
|
|
||||||
&SCOPED_NESTED,
|
|
||||||
&mut ctx,
|
|
||||||
&mut path,
|
|
||||||
&mut per_byte,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ctx.subgrammar_depth, 0,
|
ctx.subgrammar_depth, 0,
|
||||||
"subgrammar_depth must be restored to 0 after the walk",
|
"subgrammar_depth must be restored to 0 after the walk",
|
||||||
@@ -1801,9 +1795,9 @@ mod tests {
|
|||||||
kind: FailureKind::Validation(err),
|
kind: FailureKind::Validation(err),
|
||||||
..
|
..
|
||||||
} => assert_eq!(err.message_key, "parse.custom.expression_too_deep"),
|
} => assert_eq!(err.message_key, "parse.custom.expression_too_deep"),
|
||||||
other => panic!(
|
other => {
|
||||||
"expected expression_too_deep on pathological scoped nesting, got {other:?}",
|
panic!("expected expression_too_deep on pathological scoped nesting, got {other:?}",)
|
||||||
),
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1822,9 +1816,7 @@ mod tests {
|
|||||||
/// Walk a top-level SQL SELECT and return the bottom frame's
|
/// Walk a top-level SQL SELECT and return the bottom frame's
|
||||||
/// `from_scope` after the walk completes. Used to verify that
|
/// `from_scope` after the walk completes. Used to verify that
|
||||||
/// `writes_table` / `writes_table_alias` populate bindings.
|
/// `writes_table` / `writes_table_alias` populate bindings.
|
||||||
fn from_scope_after_walk(
|
fn from_scope_after_walk(input: &str) -> Vec<crate::dsl::walker::context::TableBinding> {
|
||||||
input: &str,
|
|
||||||
) -> Vec<crate::dsl::walker::context::TableBinding> {
|
|
||||||
let mut ctx = WalkContext::new();
|
let mut ctx = WalkContext::new();
|
||||||
let mut path = MatchedPath::new();
|
let mut path = MatchedPath::new();
|
||||||
let mut per_byte = Vec::new();
|
let mut per_byte = Vec::new();
|
||||||
@@ -1871,9 +1863,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn join_pushes_a_second_binding() {
|
fn join_pushes_a_second_binding() {
|
||||||
let bindings = from_scope_after_walk(
|
let bindings = from_scope_after_walk("select * from a join b on x = y");
|
||||||
"select * from a join b on x = y",
|
|
||||||
);
|
|
||||||
assert_eq!(bindings.len(), 2);
|
assert_eq!(bindings.len(), 2);
|
||||||
assert_eq!(bindings[0].table, "a");
|
assert_eq!(bindings[0].table, "a");
|
||||||
assert_eq!(bindings[1].table, "b");
|
assert_eq!(bindings[1].table, "b");
|
||||||
@@ -1881,9 +1871,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn join_with_aliases() {
|
fn join_with_aliases() {
|
||||||
let bindings = from_scope_after_walk(
|
let bindings = from_scope_after_walk("select * from a as x join b as y on x.id = y.id");
|
||||||
"select * from a as x join b as y on x.id = y.id",
|
|
||||||
);
|
|
||||||
assert_eq!(bindings.len(), 2);
|
assert_eq!(bindings.len(), 2);
|
||||||
assert_eq!(bindings[0].table, "a");
|
assert_eq!(bindings[0].table, "a");
|
||||||
assert_eq!(bindings[0].alias, Some("x".to_string()));
|
assert_eq!(bindings[0].alias, Some("x".to_string()));
|
||||||
@@ -1893,9 +1881,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn three_way_join_pushes_three_bindings() {
|
fn three_way_join_pushes_three_bindings() {
|
||||||
let bindings = from_scope_after_walk(
|
let bindings =
|
||||||
"select * from a join b on x = y left join c on y = z",
|
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.len(), 3);
|
||||||
assert_eq!(bindings[0].table, "a");
|
assert_eq!(bindings[0].table, "a");
|
||||||
assert_eq!(bindings[1].table, "b");
|
assert_eq!(bindings[1].table, "b");
|
||||||
@@ -1908,9 +1895,8 @@ mod tests {
|
|||||||
// binding into the inner scope frame; on exit, the frame
|
// binding into the inner scope frame; on exit, the frame
|
||||||
// pops and the inner binding is gone. The outer scope's
|
// pops and the inner binding is gone. The outer scope's
|
||||||
// from_scope still contains only `outer_t`.
|
// from_scope still contains only `outer_t`.
|
||||||
let bindings = from_scope_after_walk(
|
let bindings =
|
||||||
"select * from outer_t where id in (select id from inner_t)",
|
from_scope_after_walk("select * from outer_t where id in (select id from inner_t)");
|
||||||
);
|
|
||||||
assert_eq!(bindings.len(), 1);
|
assert_eq!(bindings.len(), 1);
|
||||||
assert_eq!(bindings[0].table, "outer_t");
|
assert_eq!(bindings[0].table, "outer_t");
|
||||||
}
|
}
|
||||||
@@ -1921,9 +1907,8 @@ mod tests {
|
|||||||
// body's scope frame; on body-frame exit, the inner
|
// body's scope frame; on body-frame exit, the inner
|
||||||
// binding goes away. The outer scope contains only
|
// binding goes away. The outer scope contains only
|
||||||
// the CTE-name reference `cte_x`.
|
// the CTE-name reference `cte_x`.
|
||||||
let bindings = from_scope_after_walk(
|
let bindings =
|
||||||
"with cte_x as (select * from base_table) select * from cte_x",
|
from_scope_after_walk("with cte_x as (select * from base_table) select * from cte_x");
|
||||||
);
|
|
||||||
assert_eq!(bindings.len(), 1);
|
assert_eq!(bindings.len(), 1);
|
||||||
assert_eq!(bindings[0].table, "cte_x");
|
assert_eq!(bindings[0].table, "cte_x");
|
||||||
}
|
}
|
||||||
@@ -1940,10 +1925,7 @@ mod tests {
|
|||||||
/// `cte_bindings` and `projection_aliases` after the walk.
|
/// `cte_bindings` and `projection_aliases` after the walk.
|
||||||
fn frame_state_after_walk(
|
fn frame_state_after_walk(
|
||||||
input: &str,
|
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 ctx = WalkContext::new();
|
||||||
let mut path = MatchedPath::new();
|
let mut path = MatchedPath::new();
|
||||||
let mut per_byte = Vec::new();
|
let mut per_byte = Vec::new();
|
||||||
@@ -1968,9 +1950,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cte_name_pushes_placeholder_binding() {
|
fn cte_name_pushes_placeholder_binding() {
|
||||||
let (ctes, _) = frame_state_after_walk(
|
let (ctes, _) = frame_state_after_walk("with cte_x as (select 1) select * from cte_x");
|
||||||
"with cte_x as (select 1) select * from cte_x",
|
|
||||||
);
|
|
||||||
assert_eq!(ctes.len(), 1);
|
assert_eq!(ctes.len(), 1);
|
||||||
assert_eq!(ctes[0].name, "cte_x");
|
assert_eq!(ctes[0].name, "cte_x");
|
||||||
// §10.3 stage-2 harvest produces one CteColumn per
|
// §10.3 stage-2 harvest produces one CteColumn per
|
||||||
@@ -1984,9 +1964,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multiple_ctes_push_in_order() {
|
fn multiple_ctes_push_in_order() {
|
||||||
let (ctes, _) = frame_state_after_walk(
|
let (ctes, _) =
|
||||||
"with a as (select 1), b as (select 2) select * from b",
|
frame_state_after_walk("with a as (select 1), b as (select 2) select * from b");
|
||||||
);
|
|
||||||
assert_eq!(ctes.len(), 2);
|
assert_eq!(ctes.len(), 2);
|
||||||
assert_eq!(ctes[0].name, "a");
|
assert_eq!(ctes[0].name, "a");
|
||||||
assert_eq!(ctes[1].name, "b");
|
assert_eq!(ctes[1].name, "b");
|
||||||
@@ -2006,25 +1985,20 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn projection_aliases_captured_via_as_form() {
|
fn projection_aliases_captured_via_as_form() {
|
||||||
let (_, aliases) = frame_state_after_walk(
|
let (_, aliases) = frame_state_after_walk("select a as alpha, b as beta from t");
|
||||||
"select a as alpha, b as beta from t",
|
|
||||||
);
|
|
||||||
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
|
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn projection_aliases_captured_via_bare_form() {
|
fn projection_aliases_captured_via_bare_form() {
|
||||||
let (_, aliases) = frame_state_after_walk(
|
let (_, aliases) = frame_state_after_walk("select a alpha, b beta from t");
|
||||||
"select a alpha, b beta from t",
|
|
||||||
);
|
|
||||||
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
|
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn projection_aliases_mixed_forms() {
|
fn projection_aliases_mixed_forms() {
|
||||||
let (_, aliases) = frame_state_after_walk(
|
let (_, aliases) =
|
||||||
"select a as alpha, b beta, c, d as delta from t",
|
frame_state_after_walk("select a as alpha, b beta, c, d as delta from t");
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
aliases,
|
aliases,
|
||||||
vec!["alpha".to_string(), "beta".to_string(), "delta".to_string()]
|
vec!["alpha".to_string(), "beta".to_string(), "delta".to_string()]
|
||||||
@@ -2033,8 +2007,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn projection_aliases_empty_when_no_aliases() {
|
fn projection_aliases_empty_when_no_aliases() {
|
||||||
let (_, aliases) =
|
let (_, aliases) = frame_state_after_walk("select a, b from t");
|
||||||
frame_state_after_walk("select a, b from t");
|
|
||||||
assert!(aliases.is_empty());
|
assert!(aliases.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2088,9 +2061,24 @@ mod tests {
|
|||||||
s.table_columns.insert(
|
s.table_columns.insert(
|
||||||
"users".to_string(),
|
"users".to_string(),
|
||||||
vec![
|
vec![
|
||||||
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
|
TableColumn {
|
||||||
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
|
name: "id".to_string(),
|
||||||
TableColumn { name: "age".to_string(), user_type: Type::Int, not_null: false, has_default: false },
|
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
|
s
|
||||||
@@ -2108,10 +2096,7 @@ mod tests {
|
|||||||
assert_eq!(ctes.len(), 1);
|
assert_eq!(ctes.len(), 1);
|
||||||
assert_eq!(ctes[0].columns.len(), 3);
|
assert_eq!(ctes[0].columns.len(), 3);
|
||||||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id"));
|
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id"));
|
||||||
assert_eq!(
|
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||||||
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].name.as_deref(), Some("name"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ctes[0].columns[1].type_,
|
ctes[0].columns[1].type_,
|
||||||
@@ -2161,10 +2146,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(ctes[0].columns.len(), 1);
|
assert_eq!(ctes[0].columns.len(), 1);
|
||||||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("age"));
|
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("age"));
|
||||||
assert_eq!(
|
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||||||
ctes[0].columns[0].type_,
|
|
||||||
Some(crate::dsl::types::Type::Int),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2259,15 +2241,9 @@ mod tests {
|
|||||||
.expect("outer_cte binding");
|
.expect("outer_cte binding");
|
||||||
assert_eq!(outer.columns.len(), 2);
|
assert_eq!(outer.columns.len(), 2);
|
||||||
assert_eq!(outer.columns[0].name.as_deref(), Some("id"));
|
assert_eq!(outer.columns[0].name.as_deref(), Some("id"));
|
||||||
assert_eq!(
|
assert_eq!(outer.columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||||||
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].name.as_deref(), Some("name"));
|
||||||
assert_eq!(
|
assert_eq!(outer.columns[1].type_, Some(crate::dsl::types::Type::Text),);
|
||||||
outer.columns[1].type_,
|
|
||||||
Some(crate::dsl::types::Type::Text),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2287,15 +2263,9 @@ mod tests {
|
|||||||
let b = ctes.iter().find(|c| c.name == "b").expect("b binding");
|
let b = ctes.iter().find(|c| c.name == "b").expect("b binding");
|
||||||
assert_eq!(b.columns.len(), 2);
|
assert_eq!(b.columns.len(), 2);
|
||||||
assert_eq!(b.columns[0].name.as_deref(), Some("id"));
|
assert_eq!(b.columns[0].name.as_deref(), Some("id"));
|
||||||
assert_eq!(
|
assert_eq!(b.columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||||||
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].name.as_deref(), Some("name"));
|
||||||
assert_eq!(
|
assert_eq!(b.columns[1].type_, Some(crate::dsl::types::Type::Text),);
|
||||||
b.columns[1].type_,
|
|
||||||
Some(crate::dsl::types::Type::Text),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2310,10 +2280,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(ctes[0].columns.len(), 3);
|
assert_eq!(ctes[0].columns.len(), 3);
|
||||||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("a"));
|
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("a"));
|
||||||
assert_eq!(
|
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||||||
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].name.as_deref(), Some("b"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ctes[0].columns[1].type_,
|
ctes[0].columns[1].type_,
|
||||||
|
|||||||
+41
-28
@@ -24,8 +24,8 @@
|
|||||||
use crate::dsl::grammar::HighlightClass;
|
use crate::dsl::grammar::HighlightClass;
|
||||||
use crate::dsl::walker::context::WalkContext;
|
use crate::dsl::walker::context::WalkContext;
|
||||||
use crate::dsl::walker::lex_helpers::{
|
use crate::dsl::walker::lex_helpers::{
|
||||||
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
|
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
|
||||||
consume_string_literal, skip_whitespace,
|
skip_whitespace,
|
||||||
};
|
};
|
||||||
use crate::dsl::walker::outcome::{ByteClass, WalkBound};
|
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
|
/// token, producing the keyword classes the renderer needs to
|
||||||
/// colour `select` / `from` / `where` / `union` / `case` / etc.
|
/// colour `select` / `from` / `where` / `union` / `case` / etc.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn highlight_runs_in_mode(
|
pub fn highlight_runs_in_mode(source: &str, mode: crate::mode::Mode) -> Vec<ByteClass> {
|
||||||
source: &str,
|
|
||||||
mode: crate::mode::Mode,
|
|
||||||
) -> Vec<ByteClass> {
|
|
||||||
let mut ctx = WalkContext::new();
|
let mut ctx = WalkContext::new();
|
||||||
ctx.mode = mode;
|
ctx.mode = mode;
|
||||||
let (result, _cmd) = super::walk(source, WalkBound::EndOfInput, &mut ctx);
|
let (result, _cmd) = super::walk(source, WalkBound::EndOfInput, &mut ctx);
|
||||||
let mut classes: Vec<ByteClass> = result
|
let mut classes: Vec<ByteClass> = result.map(|r| r.per_byte_class).unwrap_or_default();
|
||||||
.map(|r| r.per_byte_class)
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let scan_start = classes.last().map_or(0, |c| c.end);
|
let scan_start = classes.last().map_or(0, |c| c.end);
|
||||||
scan_remainder(source, scan_start, &mut classes);
|
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)
|
.get(pos + 1)
|
||||||
.copied()
|
.copied()
|
||||||
.is_some_and(|c| c.is_ascii_digit()));
|
.is_some_and(|c| c.is_ascii_digit()));
|
||||||
if looks_like_number
|
if looks_like_number && let Some((s, e)) = consume_number_literal(source, pos) {
|
||||||
&& let Some((s, e)) = consume_number_literal(source, pos)
|
|
||||||
{
|
|
||||||
classes.push(ByteClass {
|
classes.push(ByteClass {
|
||||||
start: s,
|
start: s,
|
||||||
end: e,
|
end: e,
|
||||||
@@ -222,8 +215,14 @@ mod tests {
|
|||||||
"no Error highlight on a valid m:n line: {runs:?}"
|
"no Error highlight on a valid m:n line: {runs:?}"
|
||||||
);
|
);
|
||||||
let kinds: Vec<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect();
|
let kinds: Vec<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect();
|
||||||
assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}");
|
assert!(
|
||||||
assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}");
|
kinds.contains(&HighlightClass::Keyword),
|
||||||
|
"keywords highlighted: {runs:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
kinds.contains(&HighlightClass::Identifier),
|
||||||
|
"table names highlighted: {runs:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -276,10 +275,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn flag_classified_via_fallback() {
|
fn flag_classified_via_fallback() {
|
||||||
// Walker doesn't engage for a bare `--all-rows`.
|
// Walker doesn't engage for a bare `--all-rows`.
|
||||||
assert_eq!(
|
assert_eq!(run("--all-rows"), vec![(0, 10, HighlightClass::Flag)],);
|
||||||
run("--all-rows"),
|
|
||||||
vec![(0, 10, HighlightClass::Flag)],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -445,15 +441,13 @@ mod tests {
|
|||||||
// dispatcher, so only the entry word would highlight).
|
// dispatcher, so only the entry word would highlight).
|
||||||
let runs = run_advanced("select * from t");
|
let runs = run_advanced("select * from t");
|
||||||
assert!(
|
assert!(
|
||||||
runs.iter().any(|(s, e, c)| {
|
runs.iter()
|
||||||
*c == HighlightClass::Keyword && (*s, *e) == (0, 6)
|
.any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (0, 6) }),
|
||||||
}),
|
|
||||||
"expected `select` keyword span 0..6; got {runs:?}",
|
"expected `select` keyword span 0..6; got {runs:?}",
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
runs.iter().any(|(s, e, c)| {
|
runs.iter()
|
||||||
*c == HighlightClass::Keyword && (*s, *e) == (9, 13)
|
.any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (9, 13) }),
|
||||||
}),
|
|
||||||
"expected `from` keyword span 9..13; got {runs:?}",
|
"expected `from` keyword span 9..13; got {runs:?}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -514,18 +508,37 @@ mod tests {
|
|||||||
let insert = keywords_of(
|
let insert = keywords_of(
|
||||||
"insert into t (a) values (1) on conflict (a) do update set a = excluded.a returning a",
|
"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"] {
|
for kw in [
|
||||||
assert!(insert.contains(&kw), "INSERT/UPSERT: missing `{kw}`; got {insert:?}");
|
"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");
|
let update = keywords_of("update t set a = 1 where id = 2 returning a");
|
||||||
for kw in ["update", "set", "where", "returning"] {
|
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 *");
|
let delete = keywords_of("delete from t where id = 1 returning *");
|
||||||
for kw in ["delete", "from", "where", "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;
|
return None;
|
||||||
}
|
}
|
||||||
let mut i = start;
|
let mut i = start;
|
||||||
let leading_minus = bytes[i] == b'-'
|
let leading_minus = bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit();
|
||||||
&& i + 1 < bytes.len()
|
|
||||||
&& bytes[i + 1].is_ascii_digit();
|
|
||||||
if leading_minus {
|
if leading_minus {
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+593
-539
File diff suppressed because it is too large
Load Diff
+110
-38
@@ -14,12 +14,12 @@
|
|||||||
//! advanced effective mode (ADR-0037).
|
//! advanced effective mode (ADR-0037).
|
||||||
|
|
||||||
use crate::app::EffectiveMode;
|
use crate::app::EffectiveMode;
|
||||||
use crate::dsl::ReferentialAction;
|
|
||||||
use crate::dsl::types::Type;
|
|
||||||
use crate::dsl::Command;
|
use crate::dsl::Command;
|
||||||
|
use crate::dsl::ReferentialAction;
|
||||||
use crate::dsl::command::{
|
use crate::dsl::command::{
|
||||||
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
|
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
|
||||||
};
|
};
|
||||||
|
use crate::dsl::types::Type;
|
||||||
use crate::dsl::value::Value;
|
use crate::dsl::value::Value;
|
||||||
|
|
||||||
/// The dimmed `Executing SQL:` prefix on a teaching-echo line
|
/// The dimmed `Executing SQL:` prefix on a teaching-echo line
|
||||||
@@ -79,7 +79,12 @@ pub fn echo_for_query(
|
|||||||
name,
|
name,
|
||||||
filter,
|
filter,
|
||||||
limit,
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,12 +155,12 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
|
|||||||
column,
|
column,
|
||||||
kind,
|
kind,
|
||||||
} => match kind {
|
} => match kind {
|
||||||
ConstraintKind::NotNull => {
|
ConstraintKind::NotNull => Some(format!(
|
||||||
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"))
|
"ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"
|
||||||
}
|
)),
|
||||||
ConstraintKind::Default => {
|
ConstraintKind::Default => Some(format!(
|
||||||
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"))
|
"ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"
|
||||||
}
|
)),
|
||||||
// A column-level UNIQUE / CHECK is anonymous in our model —
|
// A column-level UNIQUE / CHECK is anonymous in our model —
|
||||||
// no portable name to DROP CONSTRAINT by, so no echo (Bucket C,
|
// no portable name to DROP CONSTRAINT by, so no echo (Bucket C,
|
||||||
// ADR-0035 Amendment 2 residual gap / ADR-0038 §7).
|
// ADR-0035 Amendment 2 residual gap / ADR-0038 §7).
|
||||||
@@ -169,7 +174,10 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
|
|||||||
table,
|
table,
|
||||||
assignments,
|
assignments,
|
||||||
filter: RowFilter::AllRows,
|
filter: RowFilter::AllRows,
|
||||||
} => Some(format!("UPDATE {table} SET {}", render_assignments(assignments))),
|
} => Some(format!(
|
||||||
|
"UPDATE {table} SET {}",
|
||||||
|
render_assignments(assignments)
|
||||||
|
)),
|
||||||
Command::Delete {
|
Command::Delete {
|
||||||
table,
|
table,
|
||||||
filter: RowFilter::AllRows,
|
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):
|
// The same column-constraint suffix `add column` emits (ADR-0029):
|
||||||
// simple-mode `create table` can carry `default` / `check` too, so
|
// simple-mode `create table` can carry `default` / `check` too, so
|
||||||
// the echo must render them or it is not equivalent (§1 contract).
|
// 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
|
s
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -299,8 +313,10 @@ pub(crate) fn render_create_m2n(
|
|||||||
primary_key: &[String],
|
primary_key: &[String],
|
||||||
foreign_keys: &[(Vec<String>, String, Vec<String>)],
|
foreign_keys: &[(Vec<String>, String, Vec<String>)],
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut parts: Vec<String> =
|
let mut parts: Vec<String> = columns
|
||||||
columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect();
|
.iter()
|
||||||
|
.map(|(n, ty)| format!("{n} {}", ty.keyword()))
|
||||||
|
.collect();
|
||||||
parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
|
parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
|
||||||
for (child_columns, parent_table, parent_columns) in foreign_keys {
|
for (child_columns, parent_table, parent_columns) in foreign_keys {
|
||||||
parts.push(format!(
|
parts.push(format!(
|
||||||
@@ -368,7 +384,12 @@ pub(crate) fn render_add_relationship_create_fk(
|
|||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
let mut lines: Vec<String> = new_columns
|
let mut lines: Vec<String> = new_columns
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
lines.push(render_add_relationship(
|
lines.push(render_add_relationship(
|
||||||
name,
|
name,
|
||||||
@@ -461,7 +482,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
|
|||||||
negated,
|
negated,
|
||||||
} => {
|
} => {
|
||||||
let not = if *negated { "NOT " } else { "" };
|
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 {
|
Predicate::Between {
|
||||||
target,
|
target,
|
||||||
@@ -484,7 +509,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
|
|||||||
} => {
|
} => {
|
||||||
let not = if *negated { "NOT " } else { "" };
|
let not = if *negated { "NOT " } else { "" };
|
||||||
let rendered: Vec<String> = items.iter().map(operand_to_sql).collect();
|
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 } => {
|
Predicate::IsNull { target, negated } => {
|
||||||
let not = if *negated { "NOT " } else { "" };
|
let not = if *negated { "NOT " } else { "" };
|
||||||
@@ -562,7 +591,10 @@ mod tests {
|
|||||||
fn create_table_compound_pk_renders_table_level() {
|
fn create_table_compound_pk_renders_table_level() {
|
||||||
let cmd = create_table(
|
let cmd = create_table(
|
||||||
"T",
|
"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"],
|
&["a", "b"],
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -594,7 +626,11 @@ mod tests {
|
|||||||
default: Some(Value::Text("A".to_string())),
|
default: Some(Value::Text("A".to_string())),
|
||||||
..ColumnSpec::new("grade", Type::Text)
|
..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");
|
let sql = command_to_sql(&cmd).expect("echo");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sql,
|
sql,
|
||||||
@@ -625,11 +661,11 @@ mod tests {
|
|||||||
check: None,
|
check: None,
|
||||||
};
|
};
|
||||||
let sql = command_to_sql(&cmd).expect("echo");
|
let sql = command_to_sql(&cmd).expect("echo");
|
||||||
assert_eq!(sql, "ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'");
|
assert_eq!(
|
||||||
assert!(matches!(
|
sql,
|
||||||
reparse(&sql),
|
"ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'"
|
||||||
Ok(Command::SqlAlterTable { .. })
|
);
|
||||||
));
|
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -657,7 +693,10 @@ mod tests {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
let sql = command_to_sql(&cmd).expect("echo");
|
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 { .. })));
|
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1031,7 +1070,10 @@ mod tests {
|
|||||||
let lines = render_drop_column_cascade(
|
let lines = render_drop_column_cascade(
|
||||||
"Orders",
|
"Orders",
|
||||||
"CustId",
|
"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!(
|
assert_eq!(
|
||||||
lines.as_slice(),
|
lines.as_slice(),
|
||||||
@@ -1043,9 +1085,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// Each line is itself runnable advanced-mode SQL (the §1 contract
|
// Each line is itself runnable advanced-mode SQL (the §1 contract
|
||||||
// holds per line for category 2).
|
// holds per line for category 2).
|
||||||
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlDropIndex { .. })));
|
assert!(matches!(
|
||||||
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlDropIndex { .. })));
|
reparse(&lines[0]),
|
||||||
assert!(matches!(reparse(&lines[2]), Ok(Command::SqlAlterTable { .. })));
|
Ok(Command::SqlDropIndex { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
reparse(&lines[1]),
|
||||||
|
Ok(Command::SqlDropIndex { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
reparse(&lines[2]),
|
||||||
|
Ok(Command::SqlAlterTable { .. })
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1054,7 +1105,10 @@ mod tests {
|
|||||||
// plain `DROP COLUMN` — still semantically equivalent.
|
// plain `DROP COLUMN` — still semantically equivalent.
|
||||||
let lines = render_drop_column_cascade("T", "c", &[]);
|
let lines = render_drop_column_cascade("T", "c", &[]);
|
||||||
assert_eq!(lines.as_slice(), &["ALTER TABLE T DROP COLUMN 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]
|
#[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",
|
"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!(
|
||||||
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlAlterTable { .. })));
|
reparse(&lines[0]),
|
||||||
|
Ok(Command::SqlAlterTable { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
reparse(&lines[1]),
|
||||||
|
Ok(Command::SqlAlterTable { .. })
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1116,8 +1176,16 @@ mod tests {
|
|||||||
],
|
],
|
||||||
&["Students_id".to_string(), "Courses_id".to_string()],
|
&["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!(
|
assert_eq!(
|
||||||
@@ -1172,8 +1240,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn value_literal_renders_null_uppercase_and_quotes_text() {
|
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::Null), "NULL");
|
||||||
assert_eq!(value_to_sql_literal(&Value::Text("O'Hara".to_string())), "'O''Hara'");
|
assert_eq!(
|
||||||
assert_eq!(value_to_sql_literal(&Value::Number("3.14".to_string())), "3.14");
|
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");
|
assert_eq!(value_to_sql_literal(&Value::Bool(false)), "false");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1258,9 +1332,7 @@ mod tests {
|
|||||||
"Command::App({app:?}) is Bucket C — no echo"
|
"Command::App({app:?}) is Bucket C — no echo"
|
||||||
);
|
);
|
||||||
// Also confirm echo_for gates the same in advanced mode.
|
// Also confirm echo_for gates the same in advanced mode.
|
||||||
assert!(
|
assert!(echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),);
|
||||||
echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -8,9 +8,8 @@
|
|||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
|
|
||||||
use crate::db::{
|
use crate::db::{
|
||||||
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
|
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, DropColumnResult,
|
||||||
DropColumnResult, InsertResult, QueryPlan, RelationshipDiagramData, TableDescription,
|
InsertResult, QueryPlan, RelationshipDiagramData, TableDescription, UpdateResult,
|
||||||
UpdateResult,
|
|
||||||
};
|
};
|
||||||
use crate::dsl::Command;
|
use crate::dsl::Command;
|
||||||
|
|
||||||
@@ -73,10 +72,16 @@ pub enum AppEvent {
|
|||||||
},
|
},
|
||||||
/// An `explain …` command succeeded (ADR-0028). `plan`
|
/// An `explain …` command succeeded (ADR-0028). `plan`
|
||||||
/// carries the captured query plan; nothing was executed.
|
/// 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
|
/// A `show <kind>` list command (V5) — carries pre-formatted
|
||||||
/// display lines (tables / relationships / indexes).
|
/// 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
|
/// `show relationship <name>` (ADR-0044) — structured data for the
|
||||||
/// diagram, rendered App-side; `None` when no such relationship.
|
/// diagram, rendered App-side; `None` when no such relationship.
|
||||||
DslShowRelationshipSucceeded {
|
DslShowRelationshipSucceeded {
|
||||||
|
|||||||
+3
-11
@@ -43,17 +43,11 @@ impl Catalog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn flatten(
|
fn flatten(value: &serde_norway::Value, prefix: String, out: &mut HashMap<String, String>) {
|
||||||
value: &serde_norway::Value,
|
|
||||||
prefix: String,
|
|
||||||
out: &mut HashMap<String, String>,
|
|
||||||
) {
|
|
||||||
match value {
|
match value {
|
||||||
serde_norway::Value::Mapping(map) => {
|
serde_norway::Value::Mapping(map) => {
|
||||||
for (k, v) in map {
|
for (k, v) in map {
|
||||||
let k_str = k
|
let k_str = k.as_str().expect("catalog keys must be strings");
|
||||||
.as_str()
|
|
||||||
.expect("catalog keys must be strings");
|
|
||||||
let next = if prefix.is_empty() {
|
let next = if prefix.is_empty() {
|
||||||
k_str.to_string()
|
k_str.to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -85,9 +79,7 @@ pub fn catalog() -> &'static Catalog {
|
|||||||
/// See module docs for failure modes.
|
/// See module docs for failure modes.
|
||||||
pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String {
|
pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String {
|
||||||
let template = catalog().get(key).unwrap_or_else(|| {
|
let template = catalog().get(key).unwrap_or_else(|| {
|
||||||
panic!(
|
panic!("missing catalog key: `{key}` (the validator should have caught this)");
|
||||||
"missing catalog key: `{key}` (the validator should have caught this)"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
substitute(template, args, key)
|
substitute(template, args, key)
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-31
@@ -41,8 +41,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("diagnostic.alias_used_as_column", &["name"]),
|
("diagnostic.alias_used_as_column", &["name"]),
|
||||||
("diagnostic.ambiguous_column", &["column", "qualifiers"]),
|
("diagnostic.ambiguous_column", &["column", "qualifiers"]),
|
||||||
("diagnostic.auto_column_overridden", &["column", "type"]),
|
("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.duplicate_cte", &["name"]),
|
||||||
("diagnostic.eq_null", &[]),
|
("diagnostic.eq_null", &[]),
|
||||||
("diagnostic.insert_arity_mismatch", &["expected", "actual"]),
|
("diagnostic.insert_arity_mismatch", &["expected", "actual"]),
|
||||||
@@ -63,7 +69,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
),
|
),
|
||||||
("diagnostic.not_null_missing", &["column"]),
|
("diagnostic.not_null_missing", &["column"]),
|
||||||
("diagnostic.like_numeric", &["column", "type"]),
|
("diagnostic.like_numeric", &["column", "type"]),
|
||||||
("diagnostic.projection_alias_misplaced", &["alias", "clause"]),
|
(
|
||||||
|
"diagnostic.projection_alias_misplaced",
|
||||||
|
&["alias", "clause"],
|
||||||
|
),
|
||||||
("diagnostic.table_used_as_column", &["name"]),
|
("diagnostic.table_used_as_column", &["name"]),
|
||||||
("diagnostic.type_mismatch", &["column", "type"]),
|
("diagnostic.type_mismatch", &["column", "type"]),
|
||||||
("diagnostic.unknown_column", &["name", "table"]),
|
("diagnostic.unknown_column", &["name", "table"]),
|
||||||
@@ -149,10 +158,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
"error.type_mismatch.change_column.headline",
|
"error.type_mismatch.change_column.headline",
|
||||||
&["table", "column", "src_type", "target_type"],
|
&["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",
|
"error.type_mismatch.insert.headline",
|
||||||
&["value", "expected_type"],
|
&["value", "expected_type"],
|
||||||
@@ -174,13 +180,15 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
// In-app `help` — framing + per-command entries keyed by
|
// In-app `help` — framing + per-command entries keyed by
|
||||||
// each CommandNode's `help_id` (ADR-0024 §help_id).
|
// each CommandNode's `help_id` (ADR-0024 §help_id).
|
||||||
("help.intro", &[]),
|
("help.intro", &[]),
|
||||||
("help.dsl_section", &[]),
|
("help.simple_section", &[]),
|
||||||
|
("help.advanced_section", &[]),
|
||||||
("help.types_reference", &[]),
|
("help.types_reference", &[]),
|
||||||
("help.detail_hint", &[]),
|
("help.detail_hint", &[]),
|
||||||
("help.unknown_topic", &["topic"]),
|
("help.unknown_topic", &["topic"]),
|
||||||
("help.app.quit", &[]),
|
("help.app.quit", &[]),
|
||||||
("help.app.help", &[]),
|
("help.app.help", &[]),
|
||||||
("help.app.hint", &[]),
|
("help.app.hint", &[]),
|
||||||
|
("help.app.version", &[]),
|
||||||
("help.app.rebuild", &[]),
|
("help.app.rebuild", &[]),
|
||||||
("help.app.save", &[]),
|
("help.app.save", &[]),
|
||||||
("help.app.new", &[]),
|
("help.app.new", &[]),
|
||||||
@@ -216,18 +224,52 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("help.data.delete", &[]),
|
("help.data.delete", &[]),
|
||||||
("help.data.replay", &[]),
|
("help.data.replay", &[]),
|
||||||
("help.data.explain", &[]),
|
("help.data.explain", &[]),
|
||||||
|
// Issue #36: advanced-mode (SQL) help pages.
|
||||||
|
("help.data.select", &[]),
|
||||||
|
("help.data.with", &[]),
|
||||||
|
("help.data.sql_insert", &[]),
|
||||||
|
("help.data.sql_update", &[]),
|
||||||
|
("help.data.sql_delete", &[]),
|
||||||
|
("help.data.explain_sql", &[]),
|
||||||
// ---- Hint panel ambient typing assistance (ADR-0022 §6) ----
|
// ---- Hint panel ambient typing assistance (ADR-0022 §6) ----
|
||||||
("hint.ambient_complete", &[]),
|
("hint.ambient_complete", &[]),
|
||||||
(
|
("hint.ambient_error_with_usage", &["message", "usage"]),
|
||||||
"hint.ambient_error_with_usage",
|
|
||||||
&["message", "usage"],
|
|
||||||
),
|
|
||||||
("hint.ambient_expected", &["expected"]),
|
("hint.ambient_expected", &["expected"]),
|
||||||
("hint.getting_started", &[]),
|
("hint.getting_started", &[]),
|
||||||
("hint.block.heading", &[]),
|
("hint.block.heading", &[]),
|
||||||
|
("hint.block.concept_heading", &[]),
|
||||||
("hint.block.what", &[]),
|
("hint.block.what", &[]),
|
||||||
("hint.block.example", &[]),
|
("hint.block.example", &[]),
|
||||||
("hint.block.concept", &[]),
|
("hint.block.concept", &[]),
|
||||||
|
// Clause-concept blocks (issue #37 / clause-concept-hints D5).
|
||||||
|
// Both-mode topics carry a mode-keyed `example` (simple + advanced);
|
||||||
|
// single-mode topics carry a plain `example`. The comprehensiveness
|
||||||
|
// test (CONCEPT_TOPICS) enforces this shape per topic.
|
||||||
|
("hint.concept.referential_actions.what", &[]),
|
||||||
|
("hint.concept.referential_actions.example.simple", &[]),
|
||||||
|
("hint.concept.referential_actions.example.advanced", &[]),
|
||||||
|
("hint.concept.referential_actions.concept", &[]),
|
||||||
|
("hint.concept.cardinality_one_to_many.what", &[]),
|
||||||
|
("hint.concept.cardinality_one_to_many.example", &[]),
|
||||||
|
("hint.concept.cardinality_one_to_many.concept", &[]),
|
||||||
|
("hint.concept.cardinality_many_to_many.what", &[]),
|
||||||
|
("hint.concept.cardinality_many_to_many.example", &[]),
|
||||||
|
("hint.concept.cardinality_many_to_many.concept", &[]),
|
||||||
|
("hint.concept.primary_key.what", &[]),
|
||||||
|
("hint.concept.primary_key.example.simple", &[]),
|
||||||
|
("hint.concept.primary_key.example.advanced", &[]),
|
||||||
|
("hint.concept.primary_key.concept", &[]),
|
||||||
|
("hint.concept.unique.what", &[]),
|
||||||
|
("hint.concept.unique.example.simple", &[]),
|
||||||
|
("hint.concept.unique.example.advanced", &[]),
|
||||||
|
("hint.concept.unique.concept", &[]),
|
||||||
|
("hint.concept.check.what", &[]),
|
||||||
|
("hint.concept.check.example.simple", &[]),
|
||||||
|
("hint.concept.check.example.advanced", &[]),
|
||||||
|
("hint.concept.check.concept", &[]),
|
||||||
|
("hint.concept.foreign_key.what", &[]),
|
||||||
|
("hint.concept.foreign_key.example", &[]),
|
||||||
|
("hint.concept.foreign_key.concept", &[]),
|
||||||
// Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars.
|
// Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars.
|
||||||
("hint.cmd.insert.what", &[]),
|
("hint.cmd.insert.what", &[]),
|
||||||
("hint.cmd.insert.example", &[]),
|
("hint.cmd.insert.example", &[]),
|
||||||
@@ -272,11 +314,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("hint.cmd.help.concept", &[]),
|
("hint.cmd.help.concept", &[]),
|
||||||
("hint.cmd.hint.what", &[]),
|
("hint.cmd.hint.what", &[]),
|
||||||
("hint.cmd.hint.example", &[]),
|
("hint.cmd.hint.example", &[]),
|
||||||
|
("hint.cmd.version.what", &[]),
|
||||||
|
("hint.cmd.version.example", &[]),
|
||||||
("hint.cmd.rebuild.what", &[]),
|
("hint.cmd.rebuild.what", &[]),
|
||||||
("hint.cmd.rebuild.example", &[]),
|
("hint.cmd.rebuild.example", &[]),
|
||||||
("hint.cmd.rebuild.concept", &[]),
|
("hint.cmd.rebuild.concept", &[]),
|
||||||
("hint.cmd.save.what", &[]),
|
("hint.cmd.save.what", &[]),
|
||||||
("hint.cmd.save.example", &[]),
|
("hint.cmd.save.example", &[]),
|
||||||
|
("hint.cmd.save.concept", &[]),
|
||||||
("hint.cmd.new.what", &[]),
|
("hint.cmd.new.what", &[]),
|
||||||
("hint.cmd.new.example", &[]),
|
("hint.cmd.new.example", &[]),
|
||||||
("hint.cmd.load.what", &[]),
|
("hint.cmd.load.what", &[]),
|
||||||
@@ -400,10 +445,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("hint.cmd.explain_sql.what", &[]),
|
("hint.cmd.explain_sql.what", &[]),
|
||||||
("hint.cmd.explain_sql.example", &[]),
|
("hint.cmd.explain_sql.example", &[]),
|
||||||
("hint.cmd.explain_sql.concept", &[]),
|
("hint.cmd.explain_sql.concept", &[]),
|
||||||
(
|
("hint.ambient_invalid_ident", &["kind", "found"]),
|
||||||
"hint.ambient_invalid_ident",
|
|
||||||
&["kind", "found"],
|
|
||||||
),
|
|
||||||
("hint.ambient_typing_name", &[]),
|
("hint.ambient_typing_name", &[]),
|
||||||
// Issue #4: introduce the advanced-mode CREATE TABLE element
|
// Issue #4: introduce the advanced-mode CREATE TABLE element
|
||||||
// slot (`create table T (`) so the otherwise-invisible
|
// slot (`create table T (`) so the otherwise-invisible
|
||||||
@@ -411,12 +453,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("hint.create_table_element", &[]),
|
("hint.create_table_element", &[]),
|
||||||
("hint.seed_count", &[]),
|
("hint.seed_count", &[]),
|
||||||
("hint.value_literal_slot", &[]),
|
("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).
|
// Per-column-type value-slot hints (ADR-0024 §Phase D).
|
||||||
("hint.value_slot_blob", &[]),
|
|
||||||
("hint.value_slot_bool", &[]),
|
("hint.value_slot_bool", &[]),
|
||||||
("hint.value_slot_date", &[]),
|
("hint.value_slot_date", &[]),
|
||||||
("hint.value_slot_datetime", &[]),
|
("hint.value_slot_datetime", &[]),
|
||||||
@@ -437,7 +475,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("parse.custom.alter_named_unique", &[]),
|
("parse.custom.alter_named_unique", &[]),
|
||||||
("parse.custom.bind_type_mismatch", &["found", "expected"]),
|
("parse.custom.bind_type_mismatch", &["found", "expected"]),
|
||||||
("parse.custom.change_column_flags_exclusive", &[]),
|
("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.create_table_needs_pk", &[]),
|
||||||
("parse.custom.expression_too_deep", &[]),
|
("parse.custom.expression_too_deep", &[]),
|
||||||
("parse.custom.insert_form_a_missing_values", &["columns"]),
|
("parse.custom.insert_form_a_missing_values", &["columns"]),
|
||||||
@@ -485,6 +526,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("parse.usage.mode", &[]),
|
("parse.usage.mode", &[]),
|
||||||
("parse.usage.new", &[]),
|
("parse.usage.new", &[]),
|
||||||
("parse.usage.quit", &[]),
|
("parse.usage.quit", &[]),
|
||||||
|
("parse.usage.version", &[]),
|
||||||
("parse.usage.rebuild", &[]),
|
("parse.usage.rebuild", &[]),
|
||||||
("parse.usage.redo", &[]),
|
("parse.usage.redo", &[]),
|
||||||
("parse.usage.replay", &[]),
|
("parse.usage.replay", &[]),
|
||||||
@@ -571,14 +613,12 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
&["table", "col_count", "col_list", "supplied", "non_auto_csv"],
|
&["table", "col_count", "col_list", "supplied", "non_auto_csv"],
|
||||||
),
|
),
|
||||||
("select.internal_table", &["table"]),
|
("select.internal_table", &["table"]),
|
||||||
(
|
("cli.invalid_value", &["flag", "value", "expected"]),
|
||||||
"cli.invalid_value",
|
|
||||||
&["flag", "value", "expected"],
|
|
||||||
),
|
|
||||||
("cli.missing_value", &["flag"]),
|
("cli.missing_value", &["flag"]),
|
||||||
("cli.multiple_paths", &["first", "second"]),
|
("cli.multiple_paths", &["first", "second"]),
|
||||||
("cli.resume_with_path", &[]),
|
("cli.resume_with_path", &[]),
|
||||||
("cli.unknown_argument", &["arg"]),
|
("cli.unknown_argument", &["arg"]),
|
||||||
|
("cli.version_line", &["version"]),
|
||||||
(
|
(
|
||||||
"archive.export_sequence_exhausted",
|
"archive.export_sequence_exhausted",
|
||||||
&["project", "target_dir", "limit"],
|
&["project", "target_dir", "limit"],
|
||||||
@@ -861,8 +901,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let declared: HashSet<&str> =
|
let declared: HashSet<&str> = KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
|
||||||
KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
|
|
||||||
for key in cat.keys() {
|
for key in cat.keys() {
|
||||||
if key.starts_with("_test.") {
|
if key.starts_with("_test.") {
|
||||||
continue;
|
continue;
|
||||||
@@ -884,9 +923,8 @@ mod tests {
|
|||||||
/// Mirror of `tests/engine_vocabulary_audit.rs::FORBIDDEN`,
|
/// Mirror of `tests/engine_vocabulary_audit.rs::FORBIDDEN`,
|
||||||
/// duplicated here so the catalog validator is self-contained
|
/// duplicated here so the catalog validator is self-contained
|
||||||
/// (no dependency on the integration-test binary).
|
/// (no dependency on the integration-test binary).
|
||||||
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] = &[
|
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] =
|
||||||
"SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA",
|
&["SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA"];
|
||||||
];
|
|
||||||
|
|
||||||
/// Detect a `{name:...}` format-specifier placeholder.
|
/// Detect a `{name:...}` format-specifier placeholder.
|
||||||
/// Doubled braces `{{` / `}}` are escapes — must skip them.
|
/// Doubled braces `{{` / `}}` are escapes — must skip them.
|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ pub mod keys;
|
|||||||
pub mod translate;
|
pub mod translate;
|
||||||
|
|
||||||
pub use error::{DiagnosticTable, FriendlyError};
|
pub use error::{DiagnosticTable, FriendlyError};
|
||||||
pub use format::{catalog, Catalog};
|
pub use format::{Catalog, catalog};
|
||||||
pub use translate::{error_hint_class, FailureContext, Operation, TranslateContext, Verbosity};
|
pub use translate::{FailureContext, Operation, TranslateContext, Verbosity, error_hint_class};
|
||||||
|
|
||||||
// `translate::translate` and `format::translate` are different
|
// `translate::translate` and `format::translate` are different
|
||||||
// callables — the former is the structured DbError → FriendlyError
|
// callables — the former is the structured DbError → FriendlyError
|
||||||
|
|||||||
@@ -162,6 +162,10 @@ error:
|
|||||||
# ---- Help text (CLI banner + in-app `help` command) ------------------
|
# ---- Help text (CLI banner + in-app `help` command) ------------------
|
||||||
# ---- CLI argument-parsing errors (stderr before TUI starts) ---------
|
# ---- CLI argument-parsing errors (stderr before TUI starts) ---------
|
||||||
cli:
|
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}"
|
missing_value: "missing value for --{flag}"
|
||||||
invalid_value: "invalid value for --{flag}: {value} (expected one of: {expected})"
|
invalid_value: "invalid value for --{flag}: {value} (expected one of: {expected})"
|
||||||
unknown_argument: "unknown argument: {arg}"
|
unknown_argument: "unknown argument: {arg}"
|
||||||
@@ -186,6 +190,7 @@ help:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
-h, --help Print this help and exit.
|
-h, --help Print this help and exit.
|
||||||
|
-V, --version Print the version and exit.
|
||||||
--theme <light|dark> Override theme (default: auto-detect).
|
--theme <light|dark> Override theme (default: auto-detect).
|
||||||
--data-dir <PATH> Use PATH as the data root instead of
|
--data-dir <PATH> Use PATH as the data root instead of
|
||||||
the OS-standard location for this run.
|
the OS-standard location for this run.
|
||||||
@@ -210,6 +215,7 @@ help:
|
|||||||
|
|
||||||
App-level commands (typed inside the app, available in both modes):
|
App-level commands (typed inside the app, available in both modes):
|
||||||
quit Exit cleanly.
|
quit Exit cleanly.
|
||||||
|
version Print the application version.
|
||||||
mode simple|advanced Switch input mode.
|
mode simple|advanced Switch input mode.
|
||||||
help Show this list of commands in-app.
|
help Show this list of commands in-app.
|
||||||
save Save the current temp project under a
|
save Save the current temp project under a
|
||||||
@@ -240,7 +246,13 @@ help:
|
|||||||
# are multi-line-capable — the renderer emits one output row
|
# are multi-line-capable — the renderer emits one output row
|
||||||
# per line so scroll math stays accurate.
|
# per line so scroll math stays accurate.
|
||||||
intro: "Supported commands:"
|
intro: "Supported commands:"
|
||||||
dsl_section: "DSL data commands (in simple mode):"
|
# Issue #36: the command list groups by mode. App-lifecycle commands list
|
||||||
|
# first (unlabelled, under the intro — they work in either mode); the rest
|
||||||
|
# split into these two sections by command category. (Replaces the old
|
||||||
|
# single "DSL data commands (in simple mode):" header, which used the banned
|
||||||
|
# "DSL" term and mis-labelled the advanced SQL forms it already contained.)
|
||||||
|
simple_section: "Simple-mode commands:"
|
||||||
|
advanced_section: "Advanced-mode (SQL) commands:"
|
||||||
# H3: footer on the full `help` list, and the not-found note
|
# H3: footer on the full `help` list, and the not-found note
|
||||||
# for `help <topic>`. `{topic}` is the word the user typed.
|
# for `help <topic>`. `{topic}` is the word the user typed.
|
||||||
detail_hint: "Type `help <command>` for detail on one command (e.g. `help insert`), or `help types` for the type reference."
|
detail_hint: "Type `help <command>` for detail on one command (e.g. `help insert`), or `help types` for the type reference."
|
||||||
@@ -258,6 +270,8 @@ help:
|
|||||||
help <command> — detailed help for one command (e.g. `help insert`)
|
help <command> — detailed help for one command (e.g. `help insert`)
|
||||||
hint: |-
|
hint: |-
|
||||||
hint — explain the most recent error (press F1 for a hint on what you're typing)
|
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 — rebuild the project database from project.yaml + data/ (with confirmation)
|
rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
|
||||||
save: |-
|
save: |-
|
||||||
@@ -360,11 +374,28 @@ help:
|
|||||||
explain show data <T> | explain update <T> ... | explain delete from <T> ...
|
explain show data <T> | explain update <T> ... | explain delete from <T> ...
|
||||||
— show how the database would run a query, without
|
— show how the database would run a query, without
|
||||||
running it (safe even for update / delete)
|
running it (safe even for update / delete)
|
||||||
explain <select|with|insert|update|delete …> (advanced mode)
|
# Issue #36: advanced-mode (SQL) forms. Each has its own help page, listed
|
||||||
— the same plan for the SQL you wrote
|
# under the "Advanced-mode (SQL) commands:" section and shown by
|
||||||
|
# `help <topic>` alongside its simple-mode sibling — so `help insert` shows
|
||||||
|
# both the simple form and `sql_insert` (like `help create` already does).
|
||||||
|
select: |-
|
||||||
|
select <cols> | * from <T> [where …] [group by …] [order by …] [limit n]
|
||||||
|
— query rows (advanced SQL)
|
||||||
|
with: |-
|
||||||
|
with <name> as (<select>) [, …] <select> — query through a named
|
||||||
|
sub-query / CTE (advanced SQL)
|
||||||
|
sql_insert: |-
|
||||||
|
insert into <T> (col, …) values (val, …) — add a row (advanced SQL)
|
||||||
|
sql_update: |-
|
||||||
|
update <T> set <col> = <val>, … where <expr> — change matching rows (advanced SQL)
|
||||||
|
sql_delete: |-
|
||||||
|
delete from <T> where <expr> — remove matching rows (advanced SQL)
|
||||||
|
explain_sql: |-
|
||||||
|
explain <select|with|insert|update|delete …> — show the plan for a SQL statement,
|
||||||
|
without running it (advanced SQL)
|
||||||
# Type reference, appended after the command list.
|
# Type reference, appended after the command list.
|
||||||
types_reference: |
|
types_reference: |
|
||||||
Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid
|
Types: text, int, real, decimal, bool, date, datetime, serial, shortid
|
||||||
Auto-generated types (serial, shortid):
|
Auto-generated types (serial, shortid):
|
||||||
serial — integer that auto-fills with the next sequence value
|
serial — integer that auto-fills with the next sequence value
|
||||||
(MAX(col)+1) on insert. Outside a primary key it carries
|
(MAX(col)+1) on insert. Outside a primary key it carries
|
||||||
@@ -395,9 +426,58 @@ hint:
|
|||||||
# `what` / `example` / `concept` parts render under.
|
# `what` / `example` / `concept` parts render under.
|
||||||
block:
|
block:
|
||||||
heading: "Hint"
|
heading: "Hint"
|
||||||
|
# Sub-heading for the clause-concept block layered beneath the
|
||||||
|
# per-form block when the cursor sits inside a recognized clause
|
||||||
|
# (issue #37 / clause-concept-hints D6).
|
||||||
|
concept_heading: "About this clause"
|
||||||
what: "What"
|
what: "What"
|
||||||
example: "Example"
|
example: "Example"
|
||||||
concept: "Concept"
|
concept: "Concept"
|
||||||
|
# ── Tier-3 clause-concept blocks (issue #37 / clause-concept-hints) ─
|
||||||
|
# Surfaced by F1 when the cursor sits inside a recognized clause,
|
||||||
|
# layered on top of the per-form `hint.cmd.*` block. Same
|
||||||
|
# `what`/`example`/`concept` shape; topics reachable in BOTH modes
|
||||||
|
# carry a mode-keyed `example` (`simple` + `advanced`) so the example
|
||||||
|
# is syntax-correct for the mode the user is actually in (ADR-0053 D6).
|
||||||
|
# Single-mode topics carry one plain `example`. Copy rules: no engine
|
||||||
|
# name; "simple mode"/"advanced mode", never "DSL".
|
||||||
|
concept:
|
||||||
|
referential_actions:
|
||||||
|
what: "Decide what happens to a child row when the parent it points at is deleted, or its key changes."
|
||||||
|
example:
|
||||||
|
simple: "add 1:n relationship from Customers.id to Orders.customer_id on delete cascade"
|
||||||
|
advanced: "create table Orders (id int primary key, customer_id int references Customers(id) on delete cascade)"
|
||||||
|
concept: "A foreign key forbids orphans by default (restrict — the delete is refused). `cascade` deletes the children along with the parent; `set null` keeps the children but clears their link. The action you pick encodes a real rule about your data."
|
||||||
|
cardinality_one_to_many:
|
||||||
|
what: "Link two tables so one parent row can own many child rows."
|
||||||
|
example: "add 1:n relationship from Customers.id to Orders.customer_id"
|
||||||
|
concept: "\"1:n\" is one parent, many children — one customer, many orders. The child table holds a foreign key column pointing back at the parent; that shared value is what groups a parent's children together."
|
||||||
|
cardinality_many_to_many:
|
||||||
|
what: "Link two tables when rows on each side can relate to many rows on the other."
|
||||||
|
example: "create m:n relationship from Students to Courses"
|
||||||
|
concept: "\"m:n\" — many students take many courses — can't be stored as one foreign key. A junction table sits between them, holding one row per pairing; it is created for you, turning the m:n into two 1:n links."
|
||||||
|
primary_key:
|
||||||
|
what: "Mark the column (or columns) that uniquely identify each row."
|
||||||
|
example:
|
||||||
|
simple: "create table Customers with pk id(serial), Name(text)"
|
||||||
|
advanced: "create table Customers (id int primary key, Name text)"
|
||||||
|
concept: "A primary key is the row's identity: every row must have one, and no two rows may share it. Other tables point at this key to reference the row. List several columns for a compound key when one column isn't unique on its own."
|
||||||
|
unique:
|
||||||
|
what: "Require that no two rows share the same value in this column."
|
||||||
|
example:
|
||||||
|
simple: "create table Customers with pk id(serial), Email(text) unique"
|
||||||
|
advanced: "create table Customers (id int primary key, Email text unique)"
|
||||||
|
concept: "Like a primary key, `unique` forbids duplicates — but a table has one primary key (its identity) and may have many `unique` columns (e.g. email, username). Unlike the primary key, a unique column may usually be left empty."
|
||||||
|
check:
|
||||||
|
what: "Attach a rule each row must satisfy before it can be saved."
|
||||||
|
example:
|
||||||
|
simple: "create table Products with pk id(serial), Price(decimal) check (Price >= 0)"
|
||||||
|
advanced: "create table Products (id int primary key, Price decimal check (Price >= 0))"
|
||||||
|
concept: "A check guards the data at write time: any insert or update that breaks the rule is refused, so invalid rows never reach the table. Use it to enforce real-world constraints — a non-negative price, a rating between 1 and 5."
|
||||||
|
foreign_key:
|
||||||
|
what: "Make this column point at a row in another table."
|
||||||
|
example: "create table Orders (id int primary key, customer_id int references Customers(id))"
|
||||||
|
concept: "A foreign key is a promise that every value here matches a real row in the parent table — no orphaned references. The parent must exist before the child can point at it; an `on delete`/`on update` action decides what happens when that parent later changes."
|
||||||
# ── Tier-3 teaching blocks (ADR-0053 D3) ──────────────────────────
|
# ── Tier-3 teaching blocks (ADR-0053 D3) ──────────────────────────
|
||||||
# Per-form command hints (`hint.cmd.<form>`) and per-class error
|
# Per-form command hints (`hint.cmd.<form>`) and per-class error
|
||||||
# hints (`hint.err.<class>`), each a `what` (1–2 sentences) / `example`
|
# hints (`hint.err.<class>`), each a `what` (1–2 sentences) / `example`
|
||||||
@@ -425,13 +505,17 @@ hint:
|
|||||||
hint:
|
hint:
|
||||||
what: "Explain the most recent error — or, pressing F1 while typing, the command you're building."
|
what: "Explain the most recent error — or, pressing F1 while typing, the command you're building."
|
||||||
example: "hint"
|
example: "hint"
|
||||||
|
version:
|
||||||
|
what: "Print the application version."
|
||||||
|
example: "version"
|
||||||
rebuild:
|
rebuild:
|
||||||
what: "Rebuild the project database from its saved text files."
|
what: "Rebuild the project database from its saved text files."
|
||||||
example: "rebuild"
|
example: "rebuild"
|
||||||
concept: "The text files (project.yaml + the data folder) are the source of truth; the database is derived and can always be rebuilt from them."
|
concept: "The text files (project.yaml + the data folder) are the source of truth; the database is derived and can always be rebuilt from them."
|
||||||
save:
|
save:
|
||||||
what: "Save the current project under a name; `save as` copies it to a new one."
|
what: "Save the current project; `save as` copies it to a new name or location."
|
||||||
example: "save as my-shop"
|
example: "save as"
|
||||||
|
concept: "On a temporary project, `save` opens a prompt to give it a permanent name; a named project auto-saves as you work, so `save` on one is already done. `save as` always prompts for a new name or path — use it to copy a project."
|
||||||
new:
|
new:
|
||||||
what: "Close the current project and start a fresh temporary one."
|
what: "Close the current project and start a fresh temporary one."
|
||||||
example: "new"
|
example: "new"
|
||||||
@@ -444,7 +528,7 @@ hint:
|
|||||||
concept: "The zip carries the schema and data as text, so anyone can rebuild the very same database from it."
|
concept: "The zip carries the schema and data as text, so anyone can rebuild the very same database from it."
|
||||||
import:
|
import:
|
||||||
what: "Unpack a project zip into a new project and switch to it."
|
what: "Unpack a project zip into a new project and switch to it."
|
||||||
example: "import my-shop.zip as shop-copy"
|
example: "import my-shop.zip as shop_copy"
|
||||||
mode:
|
mode:
|
||||||
what: "Switch between simple mode (the guided teaching commands) and advanced mode (raw SQL)."
|
what: "Switch between simple mode (the guided teaching commands) and advanced mode (raw SQL)."
|
||||||
example: "mode advanced"
|
example: "mode advanced"
|
||||||
@@ -465,9 +549,9 @@ hint:
|
|||||||
example: "copy last"
|
example: "copy last"
|
||||||
# DDL — schema-shaping commands (Phase C batch 2).
|
# DDL — schema-shaping commands (Phase C batch 2).
|
||||||
create_table:
|
create_table:
|
||||||
what: "Create a new table — its columns, their types, and a primary key."
|
what: "Create a new table and declare its primary key."
|
||||||
example: "create table Customers with pk id(serial), name(text), email(text)"
|
example: "create table Customers with pk id(serial)"
|
||||||
concept: "A table is a set of rows that share the same columns. The primary key uniquely identifies each row; a `serial` key numbers the rows for you."
|
concept: "A table is a set of rows sharing the same columns. `with pk` declares the primary key — one column, or several for a compound key; add the other columns afterwards with `add column`. A `serial` key numbers the rows for you."
|
||||||
create_m2n:
|
create_m2n:
|
||||||
what: "Create a junction table linking two tables many-to-many."
|
what: "Create a junction table linking two tables many-to-many."
|
||||||
example: "create m:n relationship from Students to Courses"
|
example: "create m:n relationship from Students to Courses"
|
||||||
@@ -606,7 +690,7 @@ hint:
|
|||||||
child_side:
|
child_side:
|
||||||
what: "The value you gave for the child column doesn't match any parent row, so the foreign key has nothing to point at."
|
what: "The value you gave for the child column doesn't match any parent row, so the foreign key has nothing to point at."
|
||||||
example: "First insert the parent (insert into Customers …), then the child that references it."
|
example: "First insert the parent (insert into Customers …), then the child that references it."
|
||||||
concept: "A foreign key is a promise that every child points at a real parent, so the parent must exist first. To allow orphans on delete instead, set the relationship's `on delete` to `set null` or `cascade`."
|
concept: "A foreign key is a promise that every child points at a real parent, so the parent must exist before a child can reference it. (`on delete` actions like `cascade` or `set null` govern the other direction — what happens to children when their parent is removed — not this one.)"
|
||||||
parent_side:
|
parent_side:
|
||||||
what: "You're deleting or changing a row that other rows point at, which would orphan those children."
|
what: "You're deleting or changing a row that other rows point at, which would orphan those children."
|
||||||
example: "Delete the child rows first, or set the relationship's `on delete` to `cascade` (remove them too) or `set null` (keep them, unlinked)."
|
example: "Delete the child rows first, or set the relationship's `on delete` to `cascade` (remove them too) or `set null` (keep them, unlinked)."
|
||||||
@@ -679,7 +763,6 @@ hint:
|
|||||||
value_slot_text: "Type a quoted string (e.g. 'Alice') or null"
|
value_slot_text: "Type a quoted string (e.g. 'Alice') or null"
|
||||||
value_slot_date: "Type a quoted date as 'YYYY-MM-DD' or null"
|
value_slot_date: "Type a quoted date as 'YYYY-MM-DD' or null"
|
||||||
value_slot_datetime: "Type a quoted datetime as 'YYYY-MM-DD HH:MM:SS' or null"
|
value_slot_datetime: "Type a quoted datetime as 'YYYY-MM-DD HH:MM:SS' or null"
|
||||||
value_slot_blob: "Type a quoted blob literal or null"
|
|
||||||
# Serial / shortid in `values (…)` form: the user must enter
|
# Serial / shortid in `values (…)` form: the user must enter
|
||||||
# something at this position (no "skip column" syntax). `null`
|
# something at this position (no "skip column" syntax). `null`
|
||||||
# is the auto-fill path (ADR-0018: serial / shortid columns
|
# is the auto-fill path (ADR-0018: serial / shortid columns
|
||||||
@@ -873,6 +956,7 @@ parse:
|
|||||||
quit: "quit"
|
quit: "quit"
|
||||||
help: "help [<command>]"
|
help: "help [<command>]"
|
||||||
hint: "hint"
|
hint: "hint"
|
||||||
|
version: "version"
|
||||||
rebuild: "rebuild"
|
rebuild: "rebuild"
|
||||||
save: "save | save as"
|
save: "save | save as"
|
||||||
new: "new"
|
new: "new"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user